amont_agent_process/
agents.rs1use std::path::{Path, PathBuf};
15
16#[derive(Clone, Copy)]
18pub enum Need {
19 PlanPanel,
21 ImplementationReview,
23}
24
25impl Need {
26 fn matches(self, file_name: &str) -> bool {
27 let Some(stem) = file_name.strip_suffix(".md") else {
28 return false;
29 };
30 match self {
31 Need::PlanPanel => stem.starts_with("plan-review-"),
32 Need::ImplementationReview => stem == "implementation-review",
33 }
34 }
35}
36
37pub fn gap_line(need: Need) -> String {
39 let what = match need {
40 Need::PlanPanel => "no `plan-review-*` agent",
41 Need::ImplementationReview => "no `implementation-review` agent",
42 };
43 format!(
44 "{what} is installed (looked in the Claude config agents/, .claude/agents/ and installed \
45 plugins), so this check cannot be satisfied here: run `grh install workflow`."
46 )
47}
48
49pub fn carried(need: Need, project: &Path) -> bool {
52 carried_in(
53 need,
54 amont_agent_guard::settings::config_dir().as_deref(),
55 project,
56 )
57}
58
59fn carried_in(need: Need, config_dir: Option<&Path>, project: &Path) -> bool {
60 let mut dirs: Vec<PathBuf> = Vec::new();
61 match config_dir {
62 Some(config) => {
63 dirs.push(config.join("agents"));
64 match plugin_roots(config) {
65 Some(roots) => dirs.extend(roots.into_iter().map(|r| r.join("agents"))),
66 None => return true,
67 }
68 }
69 None => return true,
71 }
72 dirs.push(repository_root(project).join(".claude").join("agents"));
73 dirs.iter().any(|dir| dir_has(dir, need))
74}
75
76fn repository_root(project: &Path) -> PathBuf {
79 amont_agent_guard::lsp::git_root(project).unwrap_or_else(|| project.to_path_buf())
80}
81
82fn dir_has(dir: &Path, need: Need) -> bool {
85 let entries = match std::fs::read_dir(dir) {
86 Ok(entries) => entries,
87 Err(e) => return e.kind() != std::io::ErrorKind::NotFound,
88 };
89 entries.into_iter().any(|e| match e {
91 Ok(e) => e.file_name().to_str().is_some_and(|n| need.matches(n)),
92 Err(_) => true,
93 })
94}
95
96fn subdirs(dir: &Path) -> Option<Vec<PathBuf>> {
99 let entries = match std::fs::read_dir(dir) {
100 Ok(entries) => entries,
101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Vec::new()),
102 Err(_) => return None,
103 };
104 let mut out = Vec::new();
105 for e in entries {
106 let path = e.ok()?.path();
107 if path.is_dir() {
108 out.push(path);
109 }
110 }
111 Some(out)
112}
113
114fn plugin_roots(config: &Path) -> Option<Vec<PathBuf>> {
118 use amont_agent_guard::lsp::{installed_plugins, InstalledPlugins};
119 let mut roots = Vec::new();
120 match installed_plugins(config) {
121 InstalledPlugins::Document(doc) => {
122 let listed = doc.get("plugins")?.as_object()?;
123 for installs in listed.values() {
124 for install in installs.as_array()? {
125 if let Some(path) = install.get("installPath").and_then(|p| p.as_str()) {
126 roots.push(PathBuf::from(path));
127 }
128 }
129 }
130 }
131 InstalledPlugins::Missing => {}
132 InstalledPlugins::Unreadable => return None,
133 }
134 let mut layer = vec![config.join("plugins").join("cache")];
136 for _ in 0..3 {
137 let mut next = Vec::new();
138 for dir in &layer {
139 next.extend(subdirs(dir)?);
140 }
141 layer = next;
142 }
143 roots.extend(layer);
144 Some(roots)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn scratch(name: &str) -> PathBuf {
152 let dir =
153 std::env::temp_dir().join(format!("amont-agent-agents-{}-{name}", std::process::id()));
154 let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("scratch dir");
156 dir
157 }
158
159 fn touch(path: &Path) {
160 std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
161 std::fs::write(path, "---\nname: x\n---\n").expect("file");
162 }
163
164 #[test]
165 fn nothing_installed_carries_nothing() {
166 let dir = scratch("none");
167 assert!(!carried_in(Need::PlanPanel, Some(&dir), &dir));
168 assert!(!carried_in(Need::ImplementationReview, Some(&dir), &dir));
169 }
170
171 #[test]
172 fn each_source_counts() {
173 let dir = scratch("sources");
174 let config = dir.join("config");
175 let project = dir.join("repo");
176 std::fs::create_dir_all(&project).unwrap();
177 touch(&config.join("agents/plan-review-security.md"));
178 assert!(carried_in(Need::PlanPanel, Some(&config), &project));
179 assert!(!carried_in(
180 Need::ImplementationReview,
181 Some(&config),
182 &project
183 ));
184
185 touch(&project.join(".claude/agents/implementation-review.md"));
186 assert!(carried_in(
187 Need::ImplementationReview,
188 Some(&config),
189 &project
190 ));
191 }
192
193 #[test]
194 fn a_plugins_agents_count() {
195 let dir = scratch("plugin");
196 let config = dir.join("config");
197 let plugin = dir.join("elsewhere/grh");
198 touch(&plugin.join("agents/implementation-review.md"));
199 std::fs::create_dir_all(&config).unwrap();
200 std::fs::create_dir_all(config.join("plugins")).unwrap();
201 std::fs::write(
202 config.join("plugins/installed_plugins.json"),
203 serde_json::json!({"version": 2, "plugins": {"grh@m": [{"installPath": plugin}]}})
204 .to_string(),
205 )
206 .unwrap();
207 assert!(carried_in(Need::ImplementationReview, Some(&config), &dir));
208 assert!(!carried_in(Need::PlanPanel, Some(&config), &dir));
209 }
210
211 #[test]
212 fn an_unreadable_plugin_manifest_keeps_the_hold() {
213 let dir = scratch("broken");
214 std::fs::create_dir_all(dir.join("plugins")).unwrap();
215 std::fs::write(dir.join("plugins/installed_plugins.json"), "{nope").unwrap();
216 assert!(carried_in(Need::PlanPanel, Some(&dir), &dir));
217 }
218
219 #[test]
220 fn an_unknown_config_dir_keeps_the_hold() {
221 let dir = scratch("noconfig");
222 assert!(carried_in(Need::PlanPanel, None, &dir));
223 }
224
225 #[cfg(unix)]
226 #[test]
227 fn an_unreadable_agents_dir_keeps_the_hold() {
228 use std::os::unix::fs::PermissionsExt;
229 let dir = scratch("unreadable");
230 let agents = dir.join("agents");
231 std::fs::create_dir_all(&agents).unwrap();
232 std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o000)).unwrap();
233 let readable = std::fs::read_dir(&agents).is_ok(); let held = carried_in(Need::PlanPanel, Some(&dir), &dir);
235 std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o755)).unwrap();
236 assert!(readable || held);
237 }
238
239 #[cfg(unix)]
240 #[test]
241 fn an_unreadable_plugin_cache_keeps_the_hold() {
242 use std::os::unix::fs::PermissionsExt;
243 let dir = scratch("unreadable-cache");
244 let cache = dir.join("plugins").join("cache");
245 std::fs::create_dir_all(cache.join("market")).unwrap();
246 std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o000)).unwrap();
247 let readable = std::fs::read_dir(&cache).is_ok(); let held = carried_in(Need::PlanPanel, Some(&dir), &dir);
249 std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755)).unwrap();
250 assert!(readable || held);
251 }
252
253 #[test]
254 fn only_the_named_agents_match() {
255 assert!(Need::PlanPanel.matches("plan-review-ux.md"));
256 assert!(!Need::PlanPanel.matches("plan-review-ux.txt"));
257 assert!(!Need::PlanPanel.matches("implementation-review.md"));
258 assert!(Need::ImplementationReview.matches("implementation-review.md"));
259 assert!(!Need::ImplementationReview.matches("implementation-review-x.md"));
260 }
261}