use anyhow::Result;
use chrono::Utc;
use colored::Colorize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tabled::{settings::Style, Table, Tabled};
use crate::agent::{discover_agents, AgentInfo};
use crate::paths::get_skills_install_dir;
use crate::registry::db::{
add_external_skill, get_all_external_skills, init_db, is_external_skill, remove_external_skill, save_db,
};
use crate::registry::models::{Database, ExternalSkill};
#[derive(Tabled)]
struct ExternalSkillRow {
#[tabled(rename = "Name")]
name: String,
#[tabled(rename = "Source Agent")]
source_agent: String,
#[tabled(rename = "Source Path")]
source_path: String,
#[tabled(rename = "Discovered")]
discovered: String,
}
pub fn external_list() -> Result<()> {
let db = init_db()?;
let external_skills = get_all_external_skills(&db);
if external_skills.is_empty() {
println!("{} No external skills discovered yet.", "Info:".cyan());
println!("Run 'skillshub link' or 'skillshub external scan' to discover external skills.");
return Ok(());
}
println!(
"{} External Skills (managed elsewhere, synced by skillshub):\n",
"=>".green().bold()
);
let mut rows: Vec<ExternalSkillRow> = external_skills
.iter()
.map(|(_, skill)| ExternalSkillRow {
name: skill.name.clone(),
source_agent: skill.source_agent.clone(),
source_path: skill.source_path.display().to_string(),
discovered: skill.discovered_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect();
rows.sort_by(|a, b| a.name.cmp(&b.name));
let table = Table::new(rows).with(Style::rounded()).to_string();
println!("{}", table);
Ok(())
}
pub fn external_scan() -> Result<()> {
let skills_dir = get_skills_install_dir()?;
let skills_dir_canonical = skills_dir.canonicalize().unwrap_or_else(|_| skills_dir.clone());
let mut db = init_db()?;
let agents = discover_agents();
if agents.is_empty() {
println!("{} No coding agents found.", "Info:".cyan());
return Ok(());
}
println!(
"{} Scanning {} agent(s) for external skills...",
"=>".green().bold(),
agents.len()
);
let (new_external, all_external) = discover_external_skills_internal(&agents, &mut db, &skills_dir_canonical)?;
if new_external.is_empty() {
println!(
"{} No new external skills discovered. Total tracked: {}",
"Info:".cyan(),
all_external.len()
);
} else {
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)?;
println!(
"\n{} Total external skills tracked: {}",
"Done!".green().bold(),
all_external.len()
);
}
Ok(())
}
pub fn external_forget(name: &str) -> Result<()> {
let mut db = init_db()?;
if !is_external_skill(&db, name) {
anyhow::bail!("External skill '{}' not found", name);
}
let removed = remove_external_skill(&mut db, name);
save_db(&db)?;
if let Some(skill) = removed {
println!(
"{} Stopped tracking external skill '{}' (was from {})",
"Done!".green().bold(),
name,
skill.source_agent
);
println!(
"{} The skill itself was not deleted. Symlinks in other agents will remain until removed.",
"Note:".cyan()
);
}
Ok(())
}
fn discover_external_skills_internal(
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))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_skill_dir(path: &std::path::Path) {
fs::create_dir_all(path).unwrap();
fs::write(
path.join("SKILL.md"),
"---\nname: test\ndescription: Test\n---\n# Test\n",
)
.unwrap();
}
#[test]
fn test_external_skill_row_creation() {
let row = ExternalSkillRow {
name: "test-skill".to_string(),
source_agent: ".claude".to_string(),
source_path: "/home/user/.claude/skills/test-skill".to_string(),
discovered: "2024-01-17 10:00".to_string(),
};
assert_eq!(row.name, "test-skill");
assert_eq!(row.source_agent, ".claude");
}
#[test]
fn test_discover_external_skills_empty() {
let temp = TempDir::new().unwrap();
let skillshub_dir = temp.path().join("skillshub");
fs::create_dir_all(&skillshub_dir).unwrap();
let mut db = Database::default();
let agents: Vec<AgentInfo> = vec![];
let (new_external, all_external) = discover_external_skills_internal(&agents, &mut db, &skillshub_dir).unwrap();
assert!(new_external.is_empty());
assert!(all_external.is_empty());
}
#[test]
fn test_discover_external_skills_finds_real_dirs() {
let temp = TempDir::new().unwrap();
let skillshub_dir = temp.path().join("skillshub");
fs::create_dir_all(&skillshub_dir).unwrap();
let agent_path = temp.path().join(".claude");
let skills_path = agent_path.join("skills");
let external_skill_path = skills_path.join("my-external-skill");
create_skill_dir(&external_skill_path);
let agents = vec![AgentInfo {
path: agent_path,
skills_subdir: "skills",
}];
let mut db = Database::default();
let (new_external, all_external) = discover_external_skills_internal(&agents, &mut db, &skillshub_dir).unwrap();
assert_eq!(new_external.len(), 1);
assert!(new_external.contains(&"my-external-skill".to_string()));
assert_eq!(all_external.len(), 1);
}
}