use std::path::{Path, PathBuf};
#[derive(Clone, Copy)]
pub enum Need {
PlanPanel,
ImplementationReview,
}
impl Need {
fn matches(self, file_name: &str) -> bool {
let Some(stem) = file_name.strip_suffix(".md") else {
return false;
};
match self {
Need::PlanPanel => stem.starts_with("plan-review-"),
Need::ImplementationReview => stem == "implementation-review",
}
}
}
pub fn gap_line(need: Need) -> String {
let what = match need {
Need::PlanPanel => "no `plan-review-*` agent",
Need::ImplementationReview => "no `implementation-review` agent",
};
format!(
"{what} is installed (looked in the Claude config agents/, .claude/agents/ and installed \
plugins), so this check cannot be satisfied here: run `grh install workflow`."
)
}
pub fn carried(need: Need, project: &Path) -> bool {
carried_in(
need,
amont_agent_guard::settings::config_dir().as_deref(),
project,
)
}
fn carried_in(need: Need, config_dir: Option<&Path>, project: &Path) -> bool {
let mut dirs: Vec<PathBuf> = Vec::new();
match config_dir {
Some(config) => {
dirs.push(config.join("agents"));
match plugin_roots(config) {
Some(roots) => dirs.extend(roots.into_iter().map(|r| r.join("agents"))),
None => return true,
}
}
None => return true,
}
dirs.push(repository_root(project).join(".claude").join("agents"));
dirs.iter().any(|dir| dir_has(dir, need))
}
fn repository_root(project: &Path) -> PathBuf {
amont_agent_guard::lsp::git_root(project).unwrap_or_else(|| project.to_path_buf())
}
fn dir_has(dir: &Path, need: Need) -> bool {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => return e.kind() != std::io::ErrorKind::NotFound,
};
entries.into_iter().any(|e| match e {
Ok(e) => e.file_name().to_str().is_some_and(|n| need.matches(n)),
Err(_) => true,
})
}
fn subdirs(dir: &Path) -> Option<Vec<PathBuf>> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Vec::new()),
Err(_) => return None,
};
let mut out = Vec::new();
for e in entries {
let path = e.ok()?.path();
if path.is_dir() {
out.push(path);
}
}
Some(out)
}
fn plugin_roots(config: &Path) -> Option<Vec<PathBuf>> {
use amont_agent_guard::lsp::{installed_plugins, InstalledPlugins};
let mut roots = Vec::new();
match installed_plugins(config) {
InstalledPlugins::Document(doc) => {
let listed = doc.get("plugins")?.as_object()?;
for installs in listed.values() {
for install in installs.as_array()? {
if let Some(path) = install.get("installPath").and_then(|p| p.as_str()) {
roots.push(PathBuf::from(path));
}
}
}
}
InstalledPlugins::Missing => {}
InstalledPlugins::Unreadable => return None,
}
let mut layer = vec![config.join("plugins").join("cache")];
for _ in 0..3 {
let mut next = Vec::new();
for dir in &layer {
next.extend(subdirs(dir)?);
}
layer = next;
}
roots.extend(layer);
Some(roots)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("amont-agent-agents-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
std::fs::write(path, "---\nname: x\n---\n").expect("file");
}
#[test]
fn nothing_installed_carries_nothing() {
let dir = scratch("none");
assert!(!carried_in(Need::PlanPanel, Some(&dir), &dir));
assert!(!carried_in(Need::ImplementationReview, Some(&dir), &dir));
}
#[test]
fn each_source_counts() {
let dir = scratch("sources");
let config = dir.join("config");
let project = dir.join("repo");
std::fs::create_dir_all(&project).unwrap();
touch(&config.join("agents/plan-review-security.md"));
assert!(carried_in(Need::PlanPanel, Some(&config), &project));
assert!(!carried_in(
Need::ImplementationReview,
Some(&config),
&project
));
touch(&project.join(".claude/agents/implementation-review.md"));
assert!(carried_in(
Need::ImplementationReview,
Some(&config),
&project
));
}
#[test]
fn a_plugins_agents_count() {
let dir = scratch("plugin");
let config = dir.join("config");
let plugin = dir.join("elsewhere/grh");
touch(&plugin.join("agents/implementation-review.md"));
std::fs::create_dir_all(&config).unwrap();
std::fs::create_dir_all(config.join("plugins")).unwrap();
std::fs::write(
config.join("plugins/installed_plugins.json"),
serde_json::json!({"version": 2, "plugins": {"grh@m": [{"installPath": plugin}]}})
.to_string(),
)
.unwrap();
assert!(carried_in(Need::ImplementationReview, Some(&config), &dir));
assert!(!carried_in(Need::PlanPanel, Some(&config), &dir));
}
#[test]
fn an_unreadable_plugin_manifest_keeps_the_hold() {
let dir = scratch("broken");
std::fs::create_dir_all(dir.join("plugins")).unwrap();
std::fs::write(dir.join("plugins/installed_plugins.json"), "{nope").unwrap();
assert!(carried_in(Need::PlanPanel, Some(&dir), &dir));
}
#[test]
fn an_unknown_config_dir_keeps_the_hold() {
let dir = scratch("noconfig");
assert!(carried_in(Need::PlanPanel, None, &dir));
}
#[cfg(unix)]
#[test]
fn an_unreadable_agents_dir_keeps_the_hold() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("unreadable");
let agents = dir.join("agents");
std::fs::create_dir_all(&agents).unwrap();
std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o000)).unwrap();
let readable = std::fs::read_dir(&agents).is_ok(); let held = carried_in(Need::PlanPanel, Some(&dir), &dir);
std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(readable || held);
}
#[cfg(unix)]
#[test]
fn an_unreadable_plugin_cache_keeps_the_hold() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("unreadable-cache");
let cache = dir.join("plugins").join("cache");
std::fs::create_dir_all(cache.join("market")).unwrap();
std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o000)).unwrap();
let readable = std::fs::read_dir(&cache).is_ok(); let held = carried_in(Need::PlanPanel, Some(&dir), &dir);
std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(readable || held);
}
#[test]
fn only_the_named_agents_match() {
assert!(Need::PlanPanel.matches("plan-review-ux.md"));
assert!(!Need::PlanPanel.matches("plan-review-ux.txt"));
assert!(!Need::PlanPanel.matches("implementation-review.md"));
assert!(Need::ImplementationReview.matches("implementation-review.md"));
assert!(!Need::ImplementationReview.matches("implementation-review-x.md"));
}
}