//! Skill discovery - scans directories for SKILL.md files. //! //! Discovers skills from (highest to lowest priority): //! 1. Repo: `skills/` at repo root (checked into git, overrides all) //! 2. Workspace: `.g3/skills/` (local customizations) //! 3. Extra paths from config //! 4. Global: `~/.g3/skills/` //! 5. Embedded: compiled into binary (always available) use std::collections::HashMap; use std::path::{Path, PathBuf}; use tracing::{debug, warn}; use super::embedded::get_embedded_skills; use super::parser::Skill; /// Default global skills directory const GLOBAL_SKILLS_DIR: &str = "~/.g3/skills"; /// Default workspace skills directory (relative to workspace root) const WORKSPACE_SKILLS_DIR: &str = ".g3/skills"; /// Repo-local skills directory (relative to workspace root, checked into git) const REPO_SKILLS_DIR: &str = "skills"; /// Discover all available skills from configured paths. /// /// Skills are loaded in priority order (lowest to highest): /// 1. Embedded skills (compiled into binary) /// 2. Global directory (~/.g3/skills/) /// 3. Extra paths from config /// 4. Workspace directory (.g3/skills/) /// 5. Repo directory (skills/) - highest priority /// /// Higher priority skills override lower priority skills with the same name. pub fn discover_skills( workspace_dir: Option<&Path>, extra_paths: &[PathBuf], ) -> Vec { let mut skills_by_name: HashMap = HashMap::new(); // 1. Load embedded skills first (lowest priority) load_embedded_skills(&mut skills_by_name); // 2. Load global skills let global_dir = expand_tilde(GLOBAL_SKILLS_DIR); if global_dir.exists() { debug!("Scanning global skills directory: {}", global_dir.display()); load_skills_from_dir(&global_dir, &mut skills_by_name); } // 3. Load from extra paths for path in extra_paths { let expanded = if path.starts_with("~") { expand_tilde(&path.to_string_lossy()) } else { path.clone() }; if expanded.exists() { debug!("Scanning extra skills directory: {}", expanded.display()); load_skills_from_dir(&expanded, &mut skills_by_name); } } // 4. Load workspace skills (.g3/skills/) if let Some(workspace) = workspace_dir { let workspace_skills = workspace.join(WORKSPACE_SKILLS_DIR); if workspace_skills.exists() { debug!("Scanning workspace skills directory: {}", workspace_skills.display()); load_skills_from_dir(&workspace_skills, &mut skills_by_name); } } // 5. Load repo skills (skills/) - highest priority if let Some(workspace) = workspace_dir { let repo_skills = workspace.join(REPO_SKILLS_DIR); if repo_skills.exists() { debug!("Scanning repo skills directory: {}", repo_skills.display()); load_skills_from_dir(&repo_skills, &mut skills_by_name); } } // Convert to sorted vector for deterministic ordering let mut skills: Vec = skills_by_name.into_values().collect(); skills.sort_by(|a, b| a.name.cmp(&b.name)); debug!("Discovered {} skills", skills.len()); skills } /// Load embedded skills into the map. fn load_embedded_skills(skills: &mut HashMap) { for embedded in get_embedded_skills() { match Skill::parse(embedded.skill_md, Path::new("")) { Ok(mut skill) => { // Mark as embedded in the path skill.path = format!("/{}", embedded.name, "SKILL.md"); debug!("Loaded embedded skill: {}", skill.name); skills.insert(skill.name.clone(), skill); } Err(e) => { warn!("Failed to parse embedded skill '{}': {}", embedded.name, e); } } } } /// Load skills from a directory into the map. /// Each subdirectory should contain a SKILL.md file. fn load_skills_from_dir(dir: &Path, skills: &mut HashMap) { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, Err(e) => { warn!("Failed to read skills directory {}: {}", dir.display(), e); return; } }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); // Skip non-directories if !path.is_dir() { continue; } // Look for SKILL.md in this directory let skill_file = path.join("SKILL.md"); if !skill_file.exists() { // Also check for lowercase variant let skill_file_lower = path.join("skill.md"); if skill_file_lower.exists() { load_skill_file(&skill_file_lower, skills); } continue; } load_skill_file(&skill_file, skills); } } /// Load a single skill file and add to the map. fn load_skill_file(path: &Path, skills: &mut HashMap) { match Skill::from_file(path) { Ok(skill) => { let name = skill.name.clone(); if skills.contains_key(&name) { debug!("Skill '{}' overridden by {}", name, path.display()); } skills.insert(name, skill); } Err(e) => { warn!("Failed to parse skill {}: {}", path.display(), e); } } } /// Expand tilde in path to home directory. fn expand_tilde(path: &str) -> PathBuf { let expanded = shellexpand::tilde(path); PathBuf::from(expanded.as_ref()) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn create_skill_dir(parent: &Path, name: &str, description: &str) -> PathBuf { let skill_dir = parent.join(name); fs::create_dir_all(&skill_dir).unwrap(); let content = format!( "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nSkill body.", name, description, name ); fs::write(skill_dir.join("SKILL.md"), content).unwrap(); skill_dir } #[test] fn test_discover_embedded_skills() { // With no directories, should still find embedded skills let skills = discover_skills(None, &[]); // Should have at least the research skill assert!(!skills.is_empty(), "Should have embedded skills"); assert!(skills.iter().any(|s| s.name == "research"), "Should have research skill"); } #[test] fn test_discover_from_workspace() { let temp = TempDir::new().unwrap(); let workspace = temp.path(); // Create workspace skills directory let skills_dir = workspace.join(".g3/skills"); fs::create_dir_all(&skills_dir).unwrap(); create_skill_dir(&skills_dir, "test-skill", "A test skill"); create_skill_dir(&skills_dir, "another-skill", "Another skill"); let skills = discover_skills(Some(workspace), &[]); // Should have embedded + workspace skills assert!(skills.iter().any(|s| s.name == "test-skill")); assert!(skills.iter().any(|s| s.name == "another-skill")); assert!(skills.iter().any(|s| s.name == "research")); // embedded } #[test] fn test_discover_from_repo_skills() { let temp = TempDir::new().unwrap(); let workspace = temp.path(); // Create repo skills directory (skills/) let skills_dir = workspace.join("skills"); fs::create_dir_all(&skills_dir).unwrap(); create_skill_dir(&skills_dir, "repo-skill", "A repo skill"); let skills = discover_skills(Some(workspace), &[]); assert!(skills.iter().any(|s| s.name == "repo-skill")); } #[test] fn test_repo_overrides_embedded() { let temp = TempDir::new().unwrap(); let workspace = temp.path(); // Create repo skills directory with a skill that overrides embedded let skills_dir = workspace.join("skills"); fs::create_dir_all(&skills_dir).unwrap(); // Override the embedded research skill create_skill_dir(&skills_dir, "research", "Custom research skill"); let skills = discover_skills(Some(workspace), &[]); let research = skills.iter().find(|s| s.name == "research").unwrap(); assert_eq!(research.description, "Custom research skill"); assert!(!research.path.starts_with("