Skip to content

Commit 8cc7423

Browse files
committed
feat(sprout-agent): add global ~/AGENTS.md and ~/.agents/skills/ hierarchy
sprout-agent only loaded hints from the git-root→CWD chain, missing user-level instructions entirely. Adds home directory as the first layer: ~/AGENTS.md is prepended before project hints, and ~/.agents/skills/ is scanned after CWD-relative dirs (project wins on name conflict). Includes dedup guard when ~ is already in the chain.
1 parent 5fb73f5 commit 8cc7423

2 files changed

Lines changed: 297 additions & 54 deletions

File tree

crates/sprout-agent/src/hints.rs

Lines changed: 191 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const MAX_HINTS_BYTES: usize = 128 * 1024;
77
const MAX_SKILL_BODY_BYTES: usize = 32 * 1024;
88
const 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+
1014
pub 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

147159
pub 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+
"---\nname: global-skill\ndescription: A global skill\n---\nGlobal 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+
"---\nname: shared\ndescription: from project\n---\nProject 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+
"---\nname: shared\ndescription: from global\n---\nGlobal 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+
"---\nname: local\ndescription: Local skill\n---\nBody.\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

Comments
 (0)