use anyhow::Result;
use chrono::Utc;
use colored::Colorize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::agent::{discover_agents, known_agent_names, AgentInfo};
use crate::paths::get_skills_install_dir;
use crate::registry::db::{add_external_skill, init_db, is_external_skill, save_db};
use crate::registry::models::{Database, ExternalSkill};
use crate::skill::Skill;
pub fn link_to_agents() -> Result<()> {
let skills_dir = get_skills_install_dir()?;
let mut db = init_db()?;
let agents = discover_agents();
if agents.is_empty() {
println!(
"{} No coding agents found. Looked for: {}",
"Info:".cyan(),
known_agent_names()
);
return Ok(());
}
let skills_dir_canonical = skills_dir.canonicalize().unwrap_or_else(|_| skills_dir.clone());
let (new_external, all_external) = discover_external_skills(&agents, &mut db, &skills_dir_canonical)?;
if !new_external.is_empty() {
println!(
"{} Discovered {} new external skill(s)",
"=>".green().bold(),
new_external.len()
);
for name in &new_external {
if let Some(ext) = db.external.get(name) {
println!(" {} {} (from {})", "+".green(), name, ext.source_agent);
}
}
save_db(&db)?;
}
let skills = if skills_dir.exists() {
collect_installed_skills(&skills_dir)?
} else {
Vec::new()
};
println!(
"{} Linking skills to {} discovered agent(s)",
"=>".green().bold(),
agents.len()
);
for agent in &agents {
let agent_name = agent.path.file_name().unwrap().to_string_lossy();
let link_path = agent.path.join(agent.skills_subdir);
if link_path.exists() {
if link_path.is_symlink() {
let link_target = fs::read_link(&link_path)?;
let link_target = link_target.canonicalize().unwrap_or(link_target);
if link_target == skills_dir_canonical {
fs::remove_file(&link_path)?;
fs::create_dir_all(&link_path)?;
} else {
println!(
" {} {} ({} exists but is not managed by skillshub)",
"!".red(),
agent_name,
agent.skills_subdir
);
continue;
}
} else if !link_path.is_dir() {
println!(
" {} {} ({} exists but is not a directory)",
"!".red(),
agent_name,
agent.skills_subdir
);
continue;
}
} else {
fs::create_dir_all(&link_path)?;
}
let mut linked_count = 0;
let mut skipped_count = 0;
let mut external_synced = 0;
for skill in &skills {
let link_name = skill_link_name(skill);
let skill_link_path = link_path.join(&link_name);
if skill_link_path.exists() {
if skill_link_path.is_symlink() {
linked_count += 1;
} else {
skipped_count += 1;
}
continue;
}
#[cfg(unix)]
std::os::unix::fs::symlink(&skill.path, &skill_link_path)?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&skill.path, &skill_link_path)?;
linked_count += 1;
}
for ext_skill in &all_external {
let skill_link_path = link_path.join(&ext_skill.name);
let current_agent_name = format!(".{}", agent_name);
if ext_skill.source_agent == current_agent_name || ext_skill.source_agent == agent_name {
continue;
}
if skill_link_path.exists() {
if skill_link_path.is_symlink() {
external_synced += 1;
} else {
skipped_count += 1;
}
continue;
}
#[cfg(unix)]
std::os::unix::fs::symlink(&ext_skill.source_path, &skill_link_path)?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&ext_skill.source_path, &skill_link_path)?;
external_synced += 1;
}
db.linked_agents.insert(agent_name.to_string());
let mut parts = vec![format!("linked {}", linked_count)];
if external_synced > 0 {
parts.push(format!("synced {} external", external_synced));
}
if skipped_count > 0 {
parts.push(format!("skipped {}", skipped_count));
}
println!(" {} {} ({})", "✓".green(), agent_name, parts.join(", "));
}
save_db(&db)?;
println!("\n{} Skills linked successfully!", "Done!".green().bold());
Ok(())
}
fn discover_external_skills(
agents: &[AgentInfo],
db: &mut Database,
_skillshub_skills_dir: &Path,
) -> Result<(Vec<String>, Vec<ExternalSkill>)> {
let mut new_external = Vec::new();
let mut seen_sources: HashSet<PathBuf> = HashSet::new();
let managed_skill_names: HashSet<String> = db.installed.values().map(|s| s.skill.clone()).collect();
for agent in agents {
let agent_name = agent
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let skills_path = agent.path.join(agent.skills_subdir);
if !skills_path.exists() || !skills_path.is_dir() {
continue;
}
for entry in fs::read_dir(&skills_path)? {
let entry = entry?;
let path = entry.path();
let skill_name = entry.file_name().to_string_lossy().to_string();
if managed_skill_names.contains(&skill_name) {
continue;
}
if path.is_symlink() {
continue;
}
if !path.is_dir() {
continue;
}
let source_path = path.canonicalize().unwrap_or_else(|_| path.clone());
if seen_sources.contains(&source_path) {
continue;
}
seen_sources.insert(source_path.clone());
if is_external_skill(db, &skill_name) {
continue;
}
let external = ExternalSkill {
name: skill_name.clone(),
source_agent: agent_name.clone(),
source_path,
discovered_at: Utc::now(),
};
add_external_skill(db, &skill_name, external);
new_external.push(skill_name.clone());
}
}
let all_external: Vec<ExternalSkill> = db.external.values().cloned().collect();
Ok((new_external, all_external))
}
fn skill_link_name(skill: &Skill) -> String {
skill
.path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| skill.name.clone())
}
fn collect_installed_skills(skills_dir: &Path) -> Result<Vec<Skill>> {
let mut skills = Vec::new();
if !skills_dir.exists() {
return Ok(skills);
}
fn find_skills_recursive(dir: &Path, skills: &mut Vec<Skill>) -> Result<()> {
if !dir.exists() || !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
match crate::skill::parse_skill_metadata(&skill_md) {
Ok(metadata) => {
let has_scripts = path.join("scripts").exists();
let has_references = path.join("references").exists() || path.join("resources").exists();
skills.push(Skill {
name: metadata.name,
description: metadata.description.unwrap_or_else(|| "No description".to_string()),
path,
has_scripts,
has_references,
});
}
Err(e) => {
eprintln!(
"{} Failed to parse skill at {}: {}",
"Warning:".yellow(),
path.display(),
e
);
}
}
} else {
find_skills_recursive(&path, skills)?;
}
}
Ok(())
}
find_skills_recursive(skills_dir, &mut skills)?;
let mut seen = HashSet::new();
let mut unique = Vec::new();
for skill in skills {
let link_name = skill_link_name(&skill);
if !seen.insert(link_name.clone()) {
println!(
"{} Duplicate skill name '{}' at {}",
"Warning:".yellow(),
link_name,
skill.path.display()
);
continue;
}
unique.push(skill);
}
unique.sort_by_key(skill_link_name);
Ok(unique)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_skill(path: &Path, name: &str) {
fs::create_dir_all(path).unwrap();
fs::write(
path.join("SKILL.md"),
format!("---\nname: {}\ndescription: Test skill\n---\n# {}\n", name, name),
)
.unwrap();
}
#[test]
fn test_collect_installed_skills_flattened() {
let temp = TempDir::new().unwrap();
let skills_dir = temp.path();
write_skill(&skills_dir.join("legacy-skill"), "legacy-skill");
write_skill(&skills_dir.join("tap-a").join("nested-skill"), "nested-skill");
let skills = collect_installed_skills(skills_dir).unwrap();
let names: Vec<String> = skills.iter().map(skill_link_name).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"legacy-skill".to_string()));
assert!(names.contains(&"nested-skill".to_string()));
}
}