use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::LazyLock;
use regex::Regex;
use crate::core::agent_metadata::read_agent_metadata;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_tiers::{list_project_custom_stems, list_source_stems, resolve_skill_tier};
static PROSE_SKILL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)`([A-Za-z0-9_-]+)`\**\s*skill|\bskill\s*\**`([A-Za-z0-9_-]+)`")
.expect("prose skill regex is valid")
});
fn prose_skill_mentions(body: &str, known_skills: &BTreeSet<String>) -> BTreeSet<String> {
PROSE_SKILL_RE
.captures_iter(body)
.filter_map(|caps| caps.get(1).or_else(|| caps.get(2)))
.map(|m| m.as_str().to_string())
.filter(|name| known_skills.contains(name))
.collect()
}
struct AgentSkillsScan {
agents_dir: PathBuf,
no_agents_dir: bool,
dangling: Vec<String>,
prose_hints: Vec<String>,
}
fn scan_agent_skills(paths: &FrameworkPaths) -> AgentSkillsScan {
let agents_dir = paths.claude_agents_dir();
let Ok(entries) = std::fs::read_dir(&agents_dir) else {
return AgentSkillsScan {
agents_dir,
no_agents_dir: true,
dangling: Vec::new(),
prose_hints: Vec::new(),
};
};
let bundled = list_source_stems(&paths.skill_source_dir()).unwrap_or_default();
let user = list_source_stems(&paths.user_skill_source_dir()).unwrap_or_default();
let project = list_project_custom_stems(&paths.claude_skills_dir()).unwrap_or_default();
let known_skills: BTreeSet<String> = bundled
.iter()
.chain(user.iter())
.chain(project.iter())
.cloned()
.collect();
let mut dangling: Vec<String> = Vec::new();
let mut prose_hints: Vec<String> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let meta = read_agent_metadata(&path);
for skill in &meta.skills {
if resolve_skill_tier(skill, &project, &user, &bundled).is_none() {
dangling.push(format!("{stem} declares `{skill}` (not found in any tier)"));
}
}
if let Ok(body) = std::fs::read_to_string(&path) {
for mention in prose_skill_mentions(&body, &known_skills) {
if !meta.skills.contains(&mention) {
prose_hints.push(format!(
"{stem} mentions `{mention}` in prose but does not declare it in `skills:`"
));
}
}
}
}
AgentSkillsScan {
agents_dir,
no_agents_dir: false,
dangling,
prose_hints,
}
}
pub(super) fn check_agent_skills(paths: &FrameworkPaths) -> (DoctorCheck, DoctorCheck) {
let scan = scan_agent_skills(paths);
let agent_skills = if scan.no_agents_dir {
DoctorCheck::new(
"agent_skills",
CheckStatus::Ok,
format!(
"{} not present — nothing to validate",
scan.agents_dir.display()
),
)
} else if scan.dangling.is_empty() {
DoctorCheck::new(
"agent_skills",
CheckStatus::Ok,
format!(
"every declared skill in {} resolves",
scan.agents_dir.display()
),
)
} else {
DoctorCheck::new(
"agent_skills",
CheckStatus::Warn,
format!(
"{} dangling skill reference(s): {}",
scan.dangling.len(),
scan.dangling.join("; ")
),
)
};
let agent_skills_prose_hints = if scan.no_agents_dir || scan.prose_hints.is_empty() {
DoctorCheck::new(
"agent_skills_prose_hints",
CheckStatus::Ok,
"no undeclared prose skill mentions found",
)
} else {
DoctorCheck::new(
"agent_skills_prose_hints",
CheckStatus::Ok,
format!(
"{} undeclared prose mention(s) (informational — consider adding a `skills:` \
declaration): {}",
scan.prose_hints.len(),
scan.prose_hints.join("; ")
),
)
};
(agent_skills, agent_skills_prose_hints)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn write_agent(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(format!("{name}.md")), content).unwrap();
}
#[test]
fn prose_mentions_matches_known_skill() {
let body = "load and apply the **`toolchains-rust-core`** skill.";
let hits = prose_skill_mentions(body, &set(&["toolchains-rust-core"]));
assert!(hits.contains("toolchains-rust-core"));
}
#[test]
fn prose_mentions_ignores_unknown_backticked_name() {
let body = "run the `cargo test` skill check.";
let hits = prose_skill_mentions(body, &set(&["toolchains-rust-core"]));
assert!(hits.is_empty());
}
#[test]
fn prose_mentions_reverse_order() {
let body = "load skill `code-review-standards` before reviewing.";
let hits = prose_skill_mentions(body, &set(&["code-review-standards"]));
assert!(hits.contains("code-review-standards"));
}
#[test]
fn no_agents_dir_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let (agent_skills, prose_hints) = check_agent_skills(&paths);
assert_eq!(agent_skills.status, CheckStatus::Ok);
assert_eq!(prose_hints.status, CheckStatus::Ok);
}
#[test]
fn all_skills_resolve_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
write_agent(
&agents,
"critic",
"---\nname: critic\nskills: [code-review-standards]\n---\n\nBody.\n",
);
let skill_source = paths.skill_source_dir();
std::fs::create_dir_all(&skill_source).unwrap();
std::fs::write(
skill_source.join("code-review-standards.md"),
"---\nname: code-review-standards\n---\n\nSkill body.\n",
)
.unwrap();
let (agent_skills, prose_hints) = check_agent_skills(&paths);
assert_eq!(
agent_skills.status,
CheckStatus::Ok,
"{}",
agent_skills.message
);
assert_eq!(prose_hints.status, CheckStatus::Ok);
}
#[test]
fn dangling_skill_reference_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
write_agent(
&agents,
"critic",
"---\nname: critic\nskills: [missing-skill]\n---\n\nBody.\n",
);
let (agent_skills, prose_hints) = check_agent_skills(&paths);
assert_eq!(agent_skills.status, CheckStatus::Warn);
assert!(agent_skills.message.contains("missing-skill"));
assert!(agent_skills.message.contains("critic"));
assert_eq!(prose_hints.status, CheckStatus::Ok);
}
#[test]
fn prose_mention_without_declaration_is_ok_but_reported() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
write_agent(
&agents,
"rust-engineer",
"---\nname: rust-engineer\n---\n\nload and apply the **`toolchains-rust-core`** skill.\n",
);
let skill_source = paths.skill_source_dir();
std::fs::create_dir_all(&skill_source).unwrap();
std::fs::write(
skill_source.join("toolchains-rust-core.md"),
"---\nname: toolchains-rust-core\n---\n\nSkill body.\n",
)
.unwrap();
let (agent_skills, prose_hints) = check_agent_skills(&paths);
assert_eq!(prose_hints.status, CheckStatus::Ok);
assert!(prose_hints.message.contains("toolchains-rust-core"));
assert_eq!(agent_skills.status, CheckStatus::Ok);
}
#[test]
fn prose_hints_never_escalate_agent_skills_status() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
write_agent(
&agents,
"rust-engineer",
"---\nname: rust-engineer\n---\n\nload the **`toolchains-rust-core`** skill.\n",
);
let skill_source = paths.skill_source_dir();
std::fs::create_dir_all(&skill_source).unwrap();
std::fs::write(
skill_source.join("toolchains-rust-core.md"),
"---\nname: toolchains-rust-core\n---\n\nSkill body.\n",
)
.unwrap();
let (agent_skills, _prose_hints) = check_agent_skills(&paths);
assert_eq!(agent_skills.status, CheckStatus::Ok);
}
#[test]
fn declared_skill_suppresses_prose_hint() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
write_agent(
&agents,
"rust-engineer",
"---\nname: rust-engineer\nskills: [toolchains-rust-core]\n---\n\nload the **`toolchains-rust-core`** skill.\n",
);
let skill_source = paths.skill_source_dir();
std::fs::create_dir_all(&skill_source).unwrap();
std::fs::write(
skill_source.join("toolchains-rust-core.md"),
"---\nname: toolchains-rust-core\n---\n\nSkill body.\n",
)
.unwrap();
let (agent_skills, prose_hints) = check_agent_skills(&paths);
assert_eq!(
agent_skills.status,
CheckStatus::Ok,
"{}",
agent_skills.message
);
assert_eq!(prose_hints.status, CheckStatus::Ok);
assert!(!prose_hints.message.contains("toolchains-rust-core"));
}
}