use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use crate::mcp::truncate_at_boundary; const MAX_HINTS_BYTES: usize = 128 * 1024; pub const MAX_SKILL_BODY_BYTES: usize = 32 * 1024; const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"]; fn home_dir() -> Option { std::env::var("HOME").ok().map(PathBuf::from) } #[derive(Clone)] pub struct SkillEntry { pub name: String, pub description: String, /// Absolute path to the SKILL.md file; used by `load_skill` to read on demand. pub path: PathBuf, /// Absolute paths to every non-SKILL.md file in the skill directory tree. /// Pre-enumerated at discovery time so `load_skill` can match by relative path /// without doing arbitrary filesystem lookups at call time. pub supporting_files: Vec, } /// Handles both normal repos (`.git/` dir) and worktrees (`.git` file). fn find_git_root(start: &Path) -> Option { let mut current = start.to_path_buf(); loop { if current.join(".git").exists() { return Some(current); } match current.parent() { Some(parent) => current = parent.to_path_buf(), None => return None, } } } fn load_hint_files_impl(cwd: &Path, home: Option<&Path>) -> String { let mut chain = match find_git_root(cwd) { Some(root) => { let mut c: Vec = cwd .ancestors() .take_while(|a| a.starts_with(&root)) .map(|a| a.to_path_buf()) .collect(); // ancestors() yields cwd first, root last — reverse for root→cwd. c.reverse(); c } None => vec![cwd.to_path_buf()], }; // Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain. if let Some(home) = home { if !chain.iter().any(|d| d == home) { chain.insert(0, home.to_path_buf()); } } let mut result = String::new(); for dir in &chain { let path = dir.join("AGENTS.md"); let Ok(content) = std::fs::read_to_string(&path) else { continue; }; if !result.is_empty() { result.push_str("\n\n"); } let remaining = MAX_HINTS_BYTES.saturating_sub(result.len()); if remaining == 0 { break; } if content.len() <= remaining { result.push_str(&content); } else { let truncated = truncate_at_boundary(&content, remaining); result.push_str(truncated); break; } } result } fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> { // Must start with `---` let rest = content.strip_prefix("---\n")?; // Find the closing `---` let close_pos = rest.find("\n---")?; let yaml_block = &rest[..close_pos]; let map: HashMap = serde_yaml::from_str(yaml_block).ok()?; let name = map .get("name") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string)?; let description = map .get("description") .and_then(|v| v.as_str()) .map(str::trim) .unwrap_or("") .to_string(); Some((name, description)) } fn scan_skill_dir(dir: &Path, seen: &mut HashSet, skills: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; let mut subdirs: Vec = entries .filter_map(|e| e.ok()) // Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type // (which returns FileType::Symlink for symlinks, causing is_dir() to return // false even when the symlink target is a directory). .filter(|e| { std::fs::metadata(e.path()) .map(|m| m.is_dir()) .unwrap_or(false) }) .map(|e| e.path()) .collect(); subdirs.sort(); for subdir in subdirs { let skill_md = subdir.join("SKILL.md"); let Ok(content) = std::fs::read_to_string(&skill_md) else { continue; }; let Some((name, description)) = parse_skill_frontmatter(&content) else { continue; }; if seen.contains(&name) { continue; } seen.insert(name.clone()); // Collect supporting files: every non-SKILL.md file in the skill dir tree. // Don't descend into subdirs that themselves have a SKILL.md — those are // separate skills with their own entries. let supporting_files = collect_supporting_files(&subdir); skills.push(SkillEntry { name, description, path: skill_md, supporting_files, }); } } /// Walk `skill_dir` recursively and return the absolute path of every file /// that is not `SKILL.md`. Subdirectories that contain their own `SKILL.md` /// are treated as separate skills and are not descended into. fn collect_supporting_files(skill_dir: &Path) -> Vec { let mut result = Vec::new(); let mut visited_dirs = HashSet::new(); collect_supporting_files_impl(skill_dir, &mut result, &mut visited_dirs); result.sort(); result } fn collect_supporting_files_impl( current: &Path, out: &mut Vec, visited_dirs: &mut HashSet, ) { let Ok(canonical_current) = current.canonicalize() else { return; }; if !visited_dirs.insert(canonical_current) { return; } let Ok(entries) = std::fs::read_dir(current) else { return; }; let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); items.sort_by_key(|e| e.path()); for entry in items { let path = entry.path(); // Use std::fs::metadata (follows symlinks) so symlinked subdirs and files // inside a skill directory are handled correctly. let ft = match std::fs::metadata(&path) { Ok(m) => m, Err(_) => continue, }; if ft.is_dir() { // Don't descend into subdirs that are themselves skills. if path.join("SKILL.md").is_file() { continue; } collect_supporting_files_impl(&path, out, visited_dirs); } else if ft.is_file() && path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") { out.push(path); } } } fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { let mut seen = HashSet::new(); let mut skills = Vec::new(); for dir_suffix in SKILL_DIRS { scan_skill_dir(&cwd.join(dir_suffix), &mut seen, &mut skills); } if let Some(home) = home { scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills); } skills } pub fn build_hints_section(cwd: &Path) -> (String, Vec) { build_hints_section_impl(cwd, home_dir().as_deref()) } fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec) { let hints_text = load_hint_files_impl(cwd, home); let skills = discover_skills_impl(cwd, home); if hints_text.is_empty() && skills.is_empty() { return (String::new(), skills); } let mut out = String::from("# Additional Instructions\n"); if !hints_text.is_empty() { out.push_str("\n## Project Hints\n"); out.push_str(&hints_text); out.push('\n'); } if !skills.is_empty() { out.push_str("\n## Available Skills\n"); for skill in &skills { out.push_str(&format!("- {}: {}\n", skill.name, skill.description)); } out.push_str( "\nUse the `load_skill` tool to read the full content of a skill before using it.\n", ); } (out, skills) } /// Strip the YAML frontmatter block from a skill file's content and return /// the body. If no valid frontmatter is found, returns the content unchanged. pub(crate) fn strip_frontmatter(content: &str) -> &str { let Some(rest) = content.strip_prefix("---\n") else { return content; }; let Some(close_pos) = rest.find("\n---") else { return content; }; let after = &rest[close_pos + 4..]; // skip "\n---" after.strip_prefix('\n').unwrap_or(after) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn find_git_root_normal_repo() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); std::fs::create_dir(root.join(".git")).unwrap(); assert_eq!(find_git_root(root), Some(root.to_path_buf())); } #[test] fn find_git_root_worktree() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); // .git as a file (worktree) std::fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap(); assert_eq!(find_git_root(root), Some(root.to_path_buf())); } #[test] fn find_git_root_none() { let tmp = TempDir::new().unwrap(); // No .git anywhere under tmp let result = find_git_root(tmp.path()); // In a CI environment the test itself may live inside a real git repo, // so only assert None when tmp is truly isolated (not a subpath of a git repo). // We verify by checking that any found root is NOT inside tmp. if let Some(found) = result { assert!(!found.starts_with(tmp.path())); } } #[test] fn find_git_root_from_subdirectory() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); std::fs::create_dir(root.join(".git")).unwrap(); let deep = root.join("sub").join("deep"); std::fs::create_dir_all(&deep).unwrap(); assert_eq!(find_git_root(&deep), Some(root.to_path_buf())); } #[test] fn load_hint_files_single_at_cwd() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); // No .git → no git root discovery; only cwd is checked. std::fs::write(cwd.join("AGENTS.md"), "cwd hints").unwrap(); let result = load_hint_files_impl(cwd, None); assert_eq!(result, "cwd hints"); } #[test] fn load_hint_files_git_root_and_cwd() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); std::fs::create_dir(root.join(".git")).unwrap(); std::fs::write(root.join("AGENTS.md"), "root hints").unwrap(); let sub = root.join("sub"); std::fs::create_dir(&sub).unwrap(); std::fs::write(sub.join("AGENTS.md"), "sub hints").unwrap(); let result = load_hint_files_impl(&sub, None); // Root hints must come first. assert!( result.starts_with("root hints"), "expected root hints first, got: {result:?}" ); assert!(result.contains("sub hints"), "missing sub hints"); let root_pos = result.find("root hints").unwrap(); let sub_pos = result.find("sub hints").unwrap(); assert!(root_pos < sub_pos, "root hints should precede sub hints"); } #[test] fn load_hint_files_missing_files() { let tmp = TempDir::new().unwrap(); let result = load_hint_files_impl(tmp.path(), None); assert_eq!(result, ""); } #[test] fn discover_skills_finds_across_dirs() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); // Skill in .agents/skills/ let agents_skill = cwd.join(".agents/skills/my-skill"); std::fs::create_dir_all(&agents_skill).unwrap(); std::fs::write( agents_skill.join("SKILL.md"), "---\nname: my-skill\ndescription: A skill\n---\nSkill body here.\n", ) .unwrap(); // Skill in .goose/skills/ let goose_skill = cwd.join(".goose/skills/other-skill"); std::fs::create_dir_all(&goose_skill).unwrap(); std::fs::write( goose_skill.join("SKILL.md"), "---\nname: other-skill\ndescription: Another skill\n---\nOther body.\n", ) .unwrap(); let skills = discover_skills_impl(cwd, None); assert_eq!(skills.len(), 2); let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); assert!(names.contains(&"my-skill"), "missing my-skill"); assert!(names.contains(&"other-skill"), "missing other-skill"); // Paths should point to the SKILL.md files. for skill in &skills { assert!(skill.path.exists(), "path does not exist: {:?}", skill.path); assert!(skill.path.ends_with("SKILL.md")); } } #[test] fn discover_skills_dedup_by_name() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); // Same name in .agents/skills/ (first) and .goose/skills/ (second) let agents_skill = cwd.join(".agents/skills/shared"); std::fs::create_dir_all(&agents_skill).unwrap(); std::fs::write( agents_skill.join("SKILL.md"), "---\nname: shared\ndescription: from agents\n---\nAgents body.\n", ) .unwrap(); let goose_skill = cwd.join(".goose/skills/shared"); std::fs::create_dir_all(&goose_skill).unwrap(); std::fs::write( goose_skill.join("SKILL.md"), "---\nname: shared\ndescription: from goose\n---\nGoose body.\n", ) .unwrap(); let skills = discover_skills_impl(cwd, None); assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); assert_eq!( skills[0].description, "from agents", "first wins (.agents/)" ); // Path should point to the .agents/ version (first wins). assert!(skills[0] .path .to_str() .unwrap() .contains(".agents/skills/shared")); } #[test] fn discover_skills_skips_missing_name() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); let skill_dir = cwd.join(".agents/skills/no-name"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), "---\ndescription: No name here\n---\nBody.\n", ) .unwrap(); let skills = discover_skills_impl(cwd, None); assert!(skills.is_empty(), "entry without name should be skipped"); } #[test] fn build_hints_section_empty() { let tmp = TempDir::new().unwrap(); let (result, skills) = build_hints_section_impl(tmp.path(), None); assert_eq!(result, ""); assert!(skills.is_empty()); } #[test] fn build_hints_section_combined() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); std::fs::write(cwd.join("AGENTS.md"), "Project-level hints.").unwrap(); let skill_dir = cwd.join(".agents/skills/buzz-cli"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), "---\nname: buzz-cli\ndescription: CLI reference for Buzz managed agents\n---\nUse `buzz` to manage agents.\n", ) .unwrap(); let (result, skills) = build_hints_section_impl(cwd, None); assert!( result.contains("# Additional Instructions"), "missing header" ); assert!(result.contains("## Project Hints"), "missing Project Hints"); assert!( result.contains("Project-level hints."), "missing hints content" ); assert!( result.contains("## Available Skills"), "missing Available Skills" ); assert!( result.contains("buzz-cli: CLI reference for Buzz managed agents"), "missing skill bullet" ); // Body must NOT be inlined — lazy loading only. assert!( !result.contains("Use `buzz` to manage agents."), "skill body must not be inlined in system prompt" ); // The load_skill instruction must be present. assert!( result.contains("load_skill"), "missing load_skill instruction" ); // The old ### heading format must not appear. assert!( !result.contains("### buzz-cli"), "skill body heading must not be inlined" ); // The returned skills list should contain the discovered skill. assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "buzz-cli"); } #[test] fn load_hint_files_global_loaded_first() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); std::fs::write(home.path().join("AGENTS.md"), "global hints").unwrap(); std::fs::write(cwd.path().join("AGENTS.md"), "local hints").unwrap(); let result = load_hint_files_impl(cwd.path(), Some(home.path())); let global_pos = result.find("global hints").unwrap(); let local_pos = result.find("local hints").unwrap(); assert!( global_pos < local_pos, "global hints should precede local hints" ); } #[test] fn load_hint_files_home_missing_agents_md() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); let result = load_hint_files_impl(cwd.path(), Some(home.path())); assert_eq!(result, "local only"); } #[test] fn load_hint_files_no_home_dir() { let cwd = TempDir::new().unwrap(); std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); let result = load_hint_files_impl(cwd.path(), None); assert_eq!(result, "local only"); } #[test] fn load_hint_files_dedup_when_home_in_chain() { let tmp = TempDir::new().unwrap(); let home = tmp.path(); std::fs::write(home.join("AGENTS.md"), "single load").unwrap(); let result = load_hint_files_impl(home, Some(home)); assert_eq!( result.matches("single load").count(), 1, "AGENTS.md should be loaded exactly once when CWD is home" ); } #[test] fn load_hint_files_dedup_when_home_is_git_root() { let tmp = TempDir::new().unwrap(); let home = tmp.path(); std::fs::create_dir(home.join(".git")).unwrap(); std::fs::write(home.join("AGENTS.md"), "root+home hints").unwrap(); let sub = home.join("sub"); std::fs::create_dir(&sub).unwrap(); let result = load_hint_files_impl(&sub, Some(home)); assert_eq!( result.matches("root+home hints").count(), 1, "AGENTS.md should be loaded once when home is git root" ); } #[test] fn discover_skills_global_skills_loaded() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); let skill_dir = home.path().join(".agents/skills/global-skill"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), "---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n", ) .unwrap(); let skills = discover_skills_impl(cwd.path(), Some(home.path())); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "global-skill"); } #[test] fn discover_skills_project_wins_over_global() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); let project_skill = cwd.path().join(".agents/skills/shared"); std::fs::create_dir_all(&project_skill).unwrap(); std::fs::write( project_skill.join("SKILL.md"), "---\nname: shared\ndescription: from project\n---\nProject body.\n", ) .unwrap(); let global_skill = home.path().join(".agents/skills/shared"); std::fs::create_dir_all(&global_skill).unwrap(); std::fs::write( global_skill.join("SKILL.md"), "---\nname: shared\ndescription: from global\n---\nGlobal body.\n", ) .unwrap(); let skills = discover_skills_impl(cwd.path(), Some(home.path())); assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); assert_eq!( skills[0].description, "from project", "project-level should win over global" ); } #[test] fn discover_skills_no_home_dir() { let cwd = TempDir::new().unwrap(); let skill_dir = cwd.path().join(".agents/skills/local"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), "---\nname: local\ndescription: Local skill\n---\nBody.\n", ) .unwrap(); let skills = discover_skills_impl(cwd.path(), None); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "local"); } #[test] fn collect_supporting_files_finds_non_skill_md_files() { let tmp = TempDir::new().unwrap(); let skill_dir = tmp.path(); // SKILL.md should be excluded. std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); // A references subdir with files. let refs = skill_dir.join("references"); std::fs::create_dir_all(&refs).unwrap(); std::fs::write(refs.join("foo.md"), "foo").unwrap(); std::fs::write(refs.join("bar.md"), "bar").unwrap(); // A script at the top level. std::fs::write(skill_dir.join("setup.sh"), "#!/bin/sh").unwrap(); let files = collect_supporting_files(skill_dir); let names: Vec = files .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert!( names.contains(&"foo.md".to_owned()), "missing foo.md: {names:?}" ); assert!( names.contains(&"bar.md".to_owned()), "missing bar.md: {names:?}" ); assert!( names.contains(&"setup.sh".to_owned()), "missing setup.sh: {names:?}" ); assert!( !names.contains(&"SKILL.md".to_owned()), "SKILL.md should be excluded: {names:?}" ); } #[test] fn collect_supporting_files_does_not_descend_into_nested_skills() { let tmp = TempDir::new().unwrap(); let skill_dir = tmp.path(); std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); std::fs::write(skill_dir.join("helper.sh"), "#!/bin/sh").unwrap(); // A nested subdir that is itself a skill — should not be descended into. let nested = skill_dir.join("nested-skill"); std::fs::create_dir_all(&nested).unwrap(); std::fs::write(nested.join("SKILL.md"), "---\nname: nested\n---\n").unwrap(); std::fs::write(nested.join("secret.md"), "should not appear").unwrap(); let files = collect_supporting_files(skill_dir); let names: Vec = files .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert!( names.contains(&"helper.sh".to_owned()), "missing helper.sh: {names:?}" ); assert!( !names.contains(&"secret.md".to_owned()), "nested skill's files should not appear: {names:?}" ); } #[cfg(unix)] #[test] fn collect_supporting_files_skips_symlink_cycles() { let tmp = TempDir::new().unwrap(); let skill_dir = tmp.path(); std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); let refs = skill_dir.join("references"); std::fs::create_dir_all(&refs).unwrap(); let guide = refs.join("guide.md"); std::fs::write(&guide, "guide").unwrap(); std::os::unix::fs::symlink(&refs, refs.join("loop")).unwrap(); let files = collect_supporting_files(skill_dir); assert_eq!(files, vec![guide]); } #[test] fn discover_skills_populates_supporting_files() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path(); let skill_dir = cwd.join(".agents/skills/with-refs"); std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), "---\nname: with-refs\ndescription: Has refs\n---\nBody.\n", ) .unwrap(); let refs = skill_dir.join("references"); std::fs::create_dir_all(&refs).unwrap(); std::fs::write(refs.join("guide.md"), "guide content").unwrap(); let skills = discover_skills_impl(cwd, None); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "with-refs"); assert_eq!(skills[0].supporting_files.len(), 1); assert!( skills[0].supporting_files[0].ends_with("references/guide.md"), "unexpected path: {:?}", skills[0].supporting_files[0] ); } }