Skip to main content

amont_agent_process/
agents.rs

1//! Whether the agents a review rule needs are installed anywhere Claude Code
2//! loads agents from.
3//!
4//! `plan-review-panel` and `implementation-review` hold a tool call until a
5//! reviewer agent has run. On a machine where no such agent exists the hold
6//! can never be satisfied, so the guards say how to get the agents
7//! (`grh install workflow`) instead of refusing. Any one source carrying them
8//! keeps the hold.
9//!
10//! Sources: `$CLAUDE_CONFIG_DIR` or `~/.claude` `agents/`, the repository's
11//! `.claude/agents/` (the repository root holding the working directory), and the
12//! `agents/` of every installed plugin.
13
14use std::path::{Path, PathBuf};
15
16/// The agents one rule needs.
17#[derive(Clone, Copy)]
18pub enum Need {
19    /// Any `plan-review-*` agent.
20    PlanPanel,
21    /// The `implementation-review` agent.
22    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
37/// The one line a guard says when the agents are not installed.
38pub 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
49/// Whether any source carries an agent `need` names. Evidence that cannot be
50/// read counts as carried: missing evidence is not permission.
51pub 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        // Where the agents would be is unknown: missing evidence is not permission.
70        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
76/// The repository root holding `project`, else `project` itself: the walk
77/// stops there so a home directory above the repository is not a source.
78fn repository_root(project: &Path) -> PathBuf {
79    amont_agent_guard::lsp::git_root(project).unwrap_or_else(|| project.to_path_buf())
80}
81
82/// Whether `dir` holds an agent `need` names. A directory that is absent
83/// holds none; one that cannot be read may, so it counts as carried.
84fn 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    // An entry that cannot be read may be the agent: it counts as carried.
90    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
96/// The directories inside `dir`, or `None` when `dir` exists and cannot be
97/// listed: a cache level that cannot be read may hold the agents.
98fn 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
114/// The install directory of every plugin: those `installed_plugins.json`
115/// names, and the version directories of the plugin cache. `None` when the
116/// manifest exists and cannot be understood.
117fn 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    // cache/<marketplace>/<plugin>/<version>
135    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); // a scratch dir that is absent is the normal case
155        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(); // root reads anything
234        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(); // root reads anything
248        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}