@@ -7,6 +7,10 @@ const MAX_HINTS_BYTES: usize = 128 * 1024;
77const MAX_SKILL_BODY_BYTES : usize = 32 * 1024 ;
88const SKILL_DIRS : & [ & str ] = & [ ".agents/skills" , ".goose/skills" , ".claude/skills" ] ;
99
10+ fn home_dir ( ) -> Option < PathBuf > {
11+ std:: env:: var ( "HOME" ) . ok ( ) . map ( PathBuf :: from)
12+ }
13+
1014pub struct SkillEntry {
1115 pub name : String ,
1216 pub description : String ,
@@ -27,28 +31,30 @@ fn find_git_root(start: &Path) -> Option<PathBuf> {
2731 }
2832}
2933
30- fn load_hint_files ( cwd : & Path ) -> String {
31- let dirs = match find_git_root ( cwd) {
34+ fn load_hint_files_impl ( cwd : & Path , home : Option < & Path > ) -> String {
35+ let mut chain = match find_git_root ( cwd) {
3236 Some ( root) => {
33- // Collect ancestors of cwd that are at or below root (inclusive),
34- // ordered from root → cwd.
35- let mut chain: Vec < PathBuf > = cwd
37+ let mut c: Vec < PathBuf > = cwd
3638 . ancestors ( )
37- . take_while ( |a| {
38- // Keep while the ancestor is equal to or starts with root.
39- a. starts_with ( & root)
40- } )
39+ . take_while ( |a| a. starts_with ( & root) )
4140 . map ( |a| a. to_path_buf ( ) )
4241 . collect ( ) ;
4342 // ancestors() yields cwd first, root last — reverse for root→cwd.
44- chain . reverse ( ) ;
45- chain
43+ c . reverse ( ) ;
44+ c
4645 }
4746 None => vec ! [ cwd. to_path_buf( ) ] ,
4847 } ;
4948
49+ // Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain.
50+ if let Some ( home) = home {
51+ if !chain. iter ( ) . any ( |d| d == home) {
52+ chain. insert ( 0 , home. to_path_buf ( ) ) ;
53+ }
54+ }
55+
5056 let mut result = String :: new ( ) ;
51- for dir in & dirs {
57+ for dir in & chain {
5258 let path = dir. join ( "AGENTS.md" ) ;
5359 let Ok ( content) = std:: fs:: read_to_string ( & path) else {
5460 continue ;
@@ -104,49 +110,59 @@ fn parse_skill_frontmatter(content: &str) -> Option<(String, String, String)> {
104110 Some ( ( name, description, body) )
105111}
106112
107- fn discover_skills ( cwd : & Path ) -> Vec < SkillEntry > {
108- let mut seen = HashSet :: new ( ) ;
109- let mut skills: Vec < SkillEntry > = Vec :: new ( ) ;
110-
111- for dir_suffix in SKILL_DIRS {
112- let skills_dir = cwd. join ( dir_suffix) ;
113- let Ok ( entries) = std:: fs:: read_dir ( & skills_dir) else {
113+ fn scan_skill_dir ( dir : & Path , seen : & mut HashSet < String > , skills : & mut Vec < SkillEntry > ) {
114+ let Ok ( entries) = std:: fs:: read_dir ( dir) else {
115+ return ;
116+ } ;
117+ let mut subdirs: Vec < PathBuf > = entries
118+ . filter_map ( |e| e. ok ( ) )
119+ . filter ( |e| e. file_type ( ) . map ( |t| t. is_dir ( ) ) . unwrap_or ( false ) )
120+ . map ( |e| e. path ( ) )
121+ . collect ( ) ;
122+ subdirs. sort ( ) ;
123+
124+ for subdir in subdirs {
125+ let skill_md = subdir. join ( "SKILL.md" ) ;
126+ let Ok ( content) = std:: fs:: read_to_string ( & skill_md) else {
114127 continue ;
115128 } ;
116- let mut subdirs: Vec < PathBuf > = entries
117- . filter_map ( |e| e. ok ( ) )
118- . filter ( |e| e. file_type ( ) . map ( |t| t. is_dir ( ) ) . unwrap_or ( false ) )
119- . map ( |e| e. path ( ) )
120- . collect ( ) ;
121- // Sort for deterministic ordering.
122- subdirs. sort ( ) ;
123-
124- for subdir in subdirs {
125- let skill_md = subdir. join ( "SKILL.md" ) ;
126- let Ok ( content) = std:: fs:: read_to_string ( & skill_md) else {
127- continue ;
128- } ;
129- let Some ( ( name, description, body) ) = parse_skill_frontmatter ( & content) else {
130- continue ;
131- } ;
132- if seen. contains ( & name) {
133- continue ;
134- }
135- seen. insert ( name. clone ( ) ) ;
136- skills. push ( SkillEntry {
137- name,
138- description,
139- body,
140- } ) ;
129+ let Some ( ( name, description, body) ) = parse_skill_frontmatter ( & content) else {
130+ continue ;
131+ } ;
132+ if seen. contains ( & name) {
133+ continue ;
141134 }
135+ seen. insert ( name. clone ( ) ) ;
136+ skills. push ( SkillEntry {
137+ name,
138+ description,
139+ body,
140+ } ) ;
141+ }
142+ }
143+
144+ fn discover_skills_impl ( cwd : & Path , home : Option < & Path > ) -> Vec < SkillEntry > {
145+ let mut seen = HashSet :: new ( ) ;
146+ let mut skills = Vec :: new ( ) ;
147+
148+ for dir_suffix in SKILL_DIRS {
149+ scan_skill_dir ( & cwd. join ( dir_suffix) , & mut seen, & mut skills) ;
150+ }
151+
152+ if let Some ( home) = home {
153+ scan_skill_dir ( & home. join ( ".agents/skills" ) , & mut seen, & mut skills) ;
142154 }
143155
144156 skills
145157}
146158
147159pub fn build_hints_section ( cwd : & Path ) -> String {
148- let hints_text = load_hint_files ( cwd) ;
149- let skills = discover_skills ( cwd) ;
160+ build_hints_section_impl ( cwd, home_dir ( ) . as_deref ( ) )
161+ }
162+
163+ fn build_hints_section_impl ( cwd : & Path , home : Option < & Path > ) -> String {
164+ let hints_text = load_hint_files_impl ( cwd, home) ;
165+ let skills = discover_skills_impl ( cwd, home) ;
150166
151167 if hints_text. is_empty ( ) && skills. is_empty ( ) {
152168 return String :: new ( ) ;
@@ -228,7 +244,7 @@ mod tests {
228244 let cwd = tmp. path ( ) ;
229245 // No .git → no git root discovery; only cwd is checked.
230246 std:: fs:: write ( cwd. join ( "AGENTS.md" ) , "cwd hints" ) . unwrap ( ) ;
231- let result = load_hint_files ( cwd) ;
247+ let result = load_hint_files_impl ( cwd, None ) ;
232248 assert_eq ! ( result, "cwd hints" ) ;
233249 }
234250
@@ -241,7 +257,7 @@ mod tests {
241257 let sub = root. join ( "sub" ) ;
242258 std:: fs:: create_dir ( & sub) . unwrap ( ) ;
243259 std:: fs:: write ( sub. join ( "AGENTS.md" ) , "sub hints" ) . unwrap ( ) ;
244- let result = load_hint_files ( & sub) ;
260+ let result = load_hint_files_impl ( & sub, None ) ;
245261 // Root hints must come first.
246262 assert ! (
247263 result. starts_with( "root hints" ) ,
@@ -256,7 +272,7 @@ mod tests {
256272 #[ test]
257273 fn load_hint_files_missing_files ( ) {
258274 let tmp = TempDir :: new ( ) . unwrap ( ) ;
259- let result = load_hint_files ( tmp. path ( ) ) ;
275+ let result = load_hint_files_impl ( tmp. path ( ) , None ) ;
260276 assert_eq ! ( result, "" ) ;
261277 }
262278
@@ -283,7 +299,7 @@ mod tests {
283299 )
284300 . unwrap ( ) ;
285301
286- let skills = discover_skills ( cwd) ;
302+ let skills = discover_skills_impl ( cwd, None ) ;
287303 assert_eq ! ( skills. len( ) , 2 ) ;
288304 let names: Vec < & str > = skills. iter ( ) . map ( |s| s. name . as_str ( ) ) . collect ( ) ;
289305 assert ! ( names. contains( & "my-skill" ) , "missing my-skill" ) ;
@@ -312,7 +328,7 @@ mod tests {
312328 )
313329 . unwrap ( ) ;
314330
315- let skills = discover_skills ( cwd) ;
331+ let skills = discover_skills_impl ( cwd, None ) ;
316332 assert_eq ! ( skills. len( ) , 1 , "duplicate name should be deduplicated" ) ;
317333 assert_eq ! (
318334 skills[ 0 ] . description, "from agents" ,
@@ -334,14 +350,14 @@ mod tests {
334350 )
335351 . unwrap ( ) ;
336352
337- let skills = discover_skills ( cwd) ;
353+ let skills = discover_skills_impl ( cwd, None ) ;
338354 assert ! ( skills. is_empty( ) , "entry without name should be skipped" ) ;
339355 }
340356
341357 #[ test]
342358 fn build_hints_section_empty ( ) {
343359 let tmp = TempDir :: new ( ) . unwrap ( ) ;
344- let result = build_hints_section ( tmp. path ( ) ) ;
360+ let result = build_hints_section_impl ( tmp. path ( ) , None ) ;
345361 assert_eq ! ( result, "" ) ;
346362 }
347363
@@ -360,7 +376,7 @@ mod tests {
360376 )
361377 . unwrap ( ) ;
362378
363- let result = build_hints_section ( cwd) ;
379+ let result = build_hints_section_impl ( cwd, None ) ;
364380
365381 assert ! (
366382 result. contains( "# Additional Instructions" ) ,
@@ -385,4 +401,125 @@ mod tests {
385401 "missing skill body"
386402 ) ;
387403 }
404+
405+ #[ test]
406+ fn load_hint_files_global_loaded_first ( ) {
407+ let home = TempDir :: new ( ) . unwrap ( ) ;
408+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
409+ std:: fs:: write ( home. path ( ) . join ( "AGENTS.md" ) , "global hints" ) . unwrap ( ) ;
410+ std:: fs:: write ( cwd. path ( ) . join ( "AGENTS.md" ) , "local hints" ) . unwrap ( ) ;
411+ let result = load_hint_files_impl ( cwd. path ( ) , Some ( home. path ( ) ) ) ;
412+ let global_pos = result. find ( "global hints" ) . unwrap ( ) ;
413+ let local_pos = result. find ( "local hints" ) . unwrap ( ) ;
414+ assert ! (
415+ global_pos < local_pos,
416+ "global hints should precede local hints"
417+ ) ;
418+ }
419+
420+ #[ test]
421+ fn load_hint_files_home_missing_agents_md ( ) {
422+ let home = TempDir :: new ( ) . unwrap ( ) ;
423+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
424+ std:: fs:: write ( cwd. path ( ) . join ( "AGENTS.md" ) , "local only" ) . unwrap ( ) ;
425+ let result = load_hint_files_impl ( cwd. path ( ) , Some ( home. path ( ) ) ) ;
426+ assert_eq ! ( result, "local only" ) ;
427+ }
428+
429+ #[ test]
430+ fn load_hint_files_no_home_dir ( ) {
431+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
432+ std:: fs:: write ( cwd. path ( ) . join ( "AGENTS.md" ) , "local only" ) . unwrap ( ) ;
433+ let result = load_hint_files_impl ( cwd. path ( ) , None ) ;
434+ assert_eq ! ( result, "local only" ) ;
435+ }
436+
437+ #[ test]
438+ fn load_hint_files_dedup_when_home_in_chain ( ) {
439+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
440+ let home = tmp. path ( ) ;
441+ std:: fs:: write ( home. join ( "AGENTS.md" ) , "single load" ) . unwrap ( ) ;
442+ let result = load_hint_files_impl ( home, Some ( home) ) ;
443+ assert_eq ! (
444+ result. matches( "single load" ) . count( ) ,
445+ 1 ,
446+ "AGENTS.md should be loaded exactly once when CWD is home"
447+ ) ;
448+ }
449+
450+ #[ test]
451+ fn load_hint_files_dedup_when_home_is_git_root ( ) {
452+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
453+ let home = tmp. path ( ) ;
454+ std:: fs:: create_dir ( home. join ( ".git" ) ) . unwrap ( ) ;
455+ std:: fs:: write ( home. join ( "AGENTS.md" ) , "root+home hints" ) . unwrap ( ) ;
456+ let sub = home. join ( "sub" ) ;
457+ std:: fs:: create_dir ( & sub) . unwrap ( ) ;
458+ let result = load_hint_files_impl ( & sub, Some ( home) ) ;
459+ assert_eq ! (
460+ result. matches( "root+home hints" ) . count( ) ,
461+ 1 ,
462+ "AGENTS.md should be loaded once when home is git root"
463+ ) ;
464+ }
465+
466+ #[ test]
467+ fn discover_skills_global_skills_loaded ( ) {
468+ let home = TempDir :: new ( ) . unwrap ( ) ;
469+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
470+ let skill_dir = home. path ( ) . join ( ".agents/skills/global-skill" ) ;
471+ std:: fs:: create_dir_all ( & skill_dir) . unwrap ( ) ;
472+ std:: fs:: write (
473+ skill_dir. join ( "SKILL.md" ) ,
474+ "---\n name: global-skill\n description: A global skill\n ---\n Global body.\n " ,
475+ )
476+ . unwrap ( ) ;
477+ let skills = discover_skills_impl ( cwd. path ( ) , Some ( home. path ( ) ) ) ;
478+ assert_eq ! ( skills. len( ) , 1 ) ;
479+ assert_eq ! ( skills[ 0 ] . name, "global-skill" ) ;
480+ }
481+
482+ #[ test]
483+ fn discover_skills_project_wins_over_global ( ) {
484+ let home = TempDir :: new ( ) . unwrap ( ) ;
485+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
486+
487+ let project_skill = cwd. path ( ) . join ( ".agents/skills/shared" ) ;
488+ std:: fs:: create_dir_all ( & project_skill) . unwrap ( ) ;
489+ std:: fs:: write (
490+ project_skill. join ( "SKILL.md" ) ,
491+ "---\n name: shared\n description: from project\n ---\n Project body.\n " ,
492+ )
493+ . unwrap ( ) ;
494+
495+ let global_skill = home. path ( ) . join ( ".agents/skills/shared" ) ;
496+ std:: fs:: create_dir_all ( & global_skill) . unwrap ( ) ;
497+ std:: fs:: write (
498+ global_skill. join ( "SKILL.md" ) ,
499+ "---\n name: shared\n description: from global\n ---\n Global body.\n " ,
500+ )
501+ . unwrap ( ) ;
502+
503+ let skills = discover_skills_impl ( cwd. path ( ) , Some ( home. path ( ) ) ) ;
504+ assert_eq ! ( skills. len( ) , 1 , "duplicate name should be deduplicated" ) ;
505+ assert_eq ! (
506+ skills[ 0 ] . description, "from project" ,
507+ "project-level should win over global"
508+ ) ;
509+ }
510+
511+ #[ test]
512+ fn discover_skills_no_home_dir ( ) {
513+ let cwd = TempDir :: new ( ) . unwrap ( ) ;
514+ let skill_dir = cwd. path ( ) . join ( ".agents/skills/local" ) ;
515+ std:: fs:: create_dir_all ( & skill_dir) . unwrap ( ) ;
516+ std:: fs:: write (
517+ skill_dir. join ( "SKILL.md" ) ,
518+ "---\n name: local\n description: Local skill\n ---\n Body.\n " ,
519+ )
520+ . unwrap ( ) ;
521+ let skills = discover_skills_impl ( cwd. path ( ) , None ) ;
522+ assert_eq ! ( skills. len( ) , 1 ) ;
523+ assert_eq ! ( skills[ 0 ] . name, "local" ) ;
524+ }
388525}
0 commit comments