#![allow(
clippy::same_name_method,
reason = "rust-embed derive generates conflicting method names"
)]
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use rust_embed::RustEmbed;
use serde::Deserialize;
#[derive(RustEmbed)]
#[folder = "plugins/"]
struct PluginAssets;
const MEMORY_SUBSET_DOMAIN: &str = "doctrine-memory";
const PARTNER_SUBSET_DOMAIN: &str = "doctrine-partner";
const MARKETPLACE_ONLY_DOMAINS: &[&str] = &[MEMORY_SUBSET_DOMAIN, PARTNER_SUBSET_DOMAIN];
const DELEGATE_SOURCE: &str = "davidlee/doctrine";
#[derive(Debug, Deserialize)]
struct Meta {
name: String,
description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Entry {
domain: String,
id: String,
description: String,
files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Agent {
Claude,
Other(String),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Canonical {
id: String,
dest: PathBuf,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AgentPlan {
Claude {
canonical: Vec<Canonical>,
links: Vec<Link>,
},
Delegate {
agent: String,
argv: Vec<String>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Plan {
root: PathBuf,
items: Vec<AgentPlan>,
}
fn parse_meta(md: &str) -> anyhow::Result<Meta> {
let after = md
.strip_prefix("---")
.context("SKILL.md missing leading '---' frontmatter")?
.trim_start_matches(['\r', '\n']);
let end = after
.find("\n---")
.context("SKILL.md frontmatter is not terminated by '---'")?;
let yaml = after.get(..end).context("frontmatter slice out of range")?;
let meta: Meta = serde_yaml::from_str(yaml).context("Failed to parse SKILL.md frontmatter")?;
Ok(meta)
}
fn discover() -> anyhow::Result<Vec<Entry>> {
use std::collections::BTreeMap;
let mut grouped: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
for path in PluginAssets::iter() {
let p = path.as_ref();
let parts: Vec<&str> = p.split('/').collect();
if let [domain, "skills", skill, ..] = parts.as_slice() {
if MARKETPLACE_ONLY_DOMAINS.contains(domain) {
continue;
}
grouped
.entry(((*domain).to_string(), (*skill).to_string()))
.or_default()
.push(p.to_string());
}
}
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut entries = Vec::new();
for ((domain, skill), files) in grouped {
let skill_md = format!("{domain}/skills/{skill}/SKILL.md");
let asset = PluginAssets::get(&skill_md)
.with_context(|| format!("Skill '{domain}/{skill}' has no SKILL.md"))?;
let text = std::str::from_utf8(&asset.data)
.with_context(|| format!("{skill_md} is not valid UTF-8"))?;
let meta = parse_meta(text).with_context(|| format!("In {skill_md}"))?;
if meta.name != skill {
bail!(
"Skill dir '{skill}' != frontmatter name '{}' ({skill_md})",
meta.name
);
}
if !seen.insert(skill.clone()) {
bail!("Duplicate skill id '{skill}' across domains; ids must be unique");
}
entries.push(Entry {
domain,
id: skill,
description: meta.description,
files,
});
}
Ok(entries)
}
fn select<'a>(all: &'a [Entry], ids: &[String], domains: &[String]) -> Vec<&'a Entry> {
all.iter()
.filter(|e| {
let id_ok = ids.is_empty() || ids.iter().any(|i| i == &e.id);
let dom_ok = domains.is_empty() || domains.iter().any(|d| d == &e.domain);
id_ok && dom_ok
})
.collect()
}
fn validate_filters(all: &[Entry], ids: &[String], domains: &[String]) -> anyhow::Result<()> {
for id in ids {
if !all.iter().any(|e| &e.id == id) {
bail!("Unknown skill '{id}'");
}
}
for d in domains {
if !all.iter().any(|e| &e.domain == d) {
bail!("Unknown domain '{d}'");
}
}
Ok(())
}
fn subset_ids<'a>(paths: impl Iterator<Item = &'a str>, domain: &str) -> BTreeSet<String> {
paths
.filter_map(|p| match p.split('/').collect::<Vec<_>>().as_slice() {
[d, "skills", id, ..] if *d == domain => Some((*id).to_string()),
_ => None,
})
.collect()
}
fn resolve_install_ids<'a>(
only_memory: bool,
skills: &[String],
paths: impl Iterator<Item = &'a str>,
subset_domain: &str,
) -> anyhow::Result<Vec<String>> {
if !only_memory {
return Ok(skills.to_vec());
}
let ids = subset_ids(paths, subset_domain);
if ids.is_empty() {
bail!("--only-memory: no skills enumerated under '{subset_domain}'");
}
Ok(ids.into_iter().collect())
}
fn install_base(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
if global {
let home = std::env::var_os("HOME").context("HOME is not set; cannot resolve --global")?;
Ok(PathBuf::from(home))
} else {
Ok(root.to_path_buf())
}
}
fn claude_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".claude/skills"))
}
fn canonical_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".doctrine/skills"))
}
const DISPATCH_WORKER_AGENT_FILE: &str = "dispatch-worker.md";
const DISPATCH_WORKER_AGENT_ASSET: &str = "agents/claude/dispatch-worker.md";
fn claude_agents_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".claude/agents"))
}
fn agent_canonical_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".doctrine/agents"))
}
fn relative_path(from: &Path, to: &Path) -> PathBuf {
let from_c: Vec<_> = from.components().collect();
let to_c: Vec<_> = to.components().collect();
let common = from_c.iter().zip(&to_c).take_while(|(a, b)| a == b).count();
let mut rel = PathBuf::new();
for _ in common..from_c.len() {
rel.push("..");
}
for c in to_c.iter().skip(common) {
rel.push(c.as_os_str());
}
rel
}
fn relative_target(agent_skills_dir: &Path, canonical_dir: &Path, id: &str) -> PathBuf {
relative_path(agent_skills_dir, &canonical_dir.join(id))
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ForeignReason {
RealDir,
ForeignSymlink(PathBuf),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Link {
Create {
id: String,
dest: PathBuf,
target: PathBuf,
},
Relink {
id: String,
dest: PathBuf,
target: PathBuf,
},
KeepForeign {
id: String,
dest: PathBuf,
reason: ForeignReason,
},
}
fn classify_link(id: &str, dest: &Path, target: &Path) -> Link {
let Ok(meta) = fs::symlink_metadata(dest) else {
return Link::Create {
id: id.to_string(),
dest: dest.to_path_buf(),
target: target.to_path_buf(),
};
};
if !meta.file_type().is_symlink() {
return Link::KeepForeign {
id: id.to_string(),
dest: dest.to_path_buf(),
reason: ForeignReason::RealDir,
};
}
match fs::read_link(dest) {
Ok(value) if value == target => Link::Relink {
id: id.to_string(),
dest: dest.to_path_buf(),
target: target.to_path_buf(),
},
Ok(value) => Link::KeepForeign {
id: id.to_string(),
dest: dest.to_path_buf(),
reason: ForeignReason::ForeignSymlink(value),
},
Err(_) => Link::KeepForeign {
id: id.to_string(),
dest: dest.to_path_buf(),
reason: ForeignReason::ForeignSymlink(PathBuf::new()),
},
}
}
fn claude_links(skills: &[&Entry], agent_dir: &Path, canon_dir: &Path) -> Vec<Link> {
skills
.iter()
.map(|e| {
let dest = agent_dir.join(&e.id);
let target = relative_target(agent_dir, canon_dir, &e.id);
classify_link(&e.id, &dest, &target)
})
.collect()
}
fn staging_path(path: &Path) -> anyhow::Result<PathBuf> {
let parent = path.parent().context("path has no parent directory")?;
let name = path.file_name().context("path has no file name")?;
Ok(parent.join(format!(".tmp-{}", name.to_string_lossy())))
}
fn write_link(dest: &Path, target: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::symlink;
let tmp = staging_path(dest)?;
fs::remove_file(&tmp).ok();
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
symlink(target, &tmp).with_context(|| format!("Failed to stage link {}", tmp.display()))?;
fs::rename(&tmp, dest)
.with_context(|| format!("Failed to swap link {} → {}", tmp.display(), dest.display()))?;
Ok(())
}
fn foreign_reason(reason: &ForeignReason) -> String {
match reason {
ForeignReason::RealDir => "real dir".to_string(),
ForeignReason::ForeignSymlink(to) => format!("foreign symlink → {}", to.display()),
}
}
fn delegate_argv(agent: &str, skills: &[&Entry], global: bool, subset: bool) -> Vec<String> {
let mut argv = vec![
"skills".to_string(),
"add".to_string(),
DELEGATE_SOURCE.to_string(),
"--agent".to_string(),
agent.to_string(),
];
if global {
argv.push("--global".to_string());
}
if subset {
for e in skills {
argv.push("--skill".to_string());
argv.push(e.id.clone());
}
}
argv.push("--yes".to_string());
argv
}
fn build_plan(
root: &Path,
agents: &[Agent],
all: &[Entry],
ids: &[String],
domains: &[String],
global: bool,
) -> anyhow::Result<Plan> {
let selected = select(all, ids, domains);
let subset = !(ids.is_empty() && domains.is_empty());
let mut items = Vec::new();
for agent in agents {
match agent {
Agent::Claude => {
let agent_dir = claude_dir(root, global)?;
let canon_dir = canonical_dir(root, global)?;
let canonical = selected
.iter()
.map(|e| Canonical {
id: e.id.clone(),
dest: canon_dir.join(&e.id),
})
.collect();
let links = claude_links(&selected, &agent_dir, &canon_dir);
items.push(AgentPlan::Claude { canonical, links });
}
Agent::Other(name) => items.push(AgentPlan::Delegate {
agent: name.clone(),
argv: delegate_argv(name, &selected, global, subset),
}),
}
}
Ok(Plan {
root: root.to_path_buf(),
items,
})
}
fn parse_agent(s: &str) -> Agent {
if s.eq_ignore_ascii_case("claude") {
Agent::Claude
} else {
Agent::Other(s.to_string())
}
}
fn resolve_agents(explicit: &[String], root: &Path) -> anyhow::Result<Vec<Agent>> {
if !explicit.is_empty() {
return Ok(explicit.iter().map(|s| parse_agent(s)).collect());
}
if root.join(".claude").exists() {
return Ok(vec![Agent::Claude]);
}
bail!(
"No --agent given and no .claude/ found. Pass --agent <name> (e.g. claude, codex, cursor)."
)
}
pub(crate) trait Runner: std::fmt::Debug {
fn run(&self, program: &str, args: &[String]) -> anyhow::Result<bool>;
}
#[derive(Debug)]
struct Npx;
impl Runner for Npx {
fn run(&self, program: &str, args: &[String]) -> anyhow::Result<bool> {
let status = std::process::Command::new(program)
.args(args)
.status()
.with_context(|| format!("Failed to run '{program}' (is Node installed?)"))?;
Ok(status.success())
}
}
fn materialise_canonical(entry: &Entry, dest: &Path) -> anyhow::Result<()> {
let tmp = staging_path(dest)?;
match fs::symlink_metadata(&tmp) {
Ok(m) if m.file_type().is_dir() => fs::remove_dir_all(&tmp)
.with_context(|| format!("Failed to clear stale {}", tmp.display()))?,
Ok(_) => {
fs::remove_file(&tmp)
.with_context(|| format!("Failed to clear stale {}", tmp.display()))?;
}
Err(_) => {}
}
copy_skill(entry, &tmp)?;
if dest.exists() {
fs::remove_dir_all(dest).with_context(|| format!("Failed to remove {}", dest.display()))?;
}
fs::rename(&tmp, dest)
.with_context(|| format!("Failed to swap {} → {}", tmp.display(), dest.display()))?;
Ok(())
}
fn copy_skill(entry: &Entry, dest: &Path) -> anyhow::Result<()> {
let prefix = format!("{}/skills/{}/", entry.domain, entry.id);
for file in &entry.files {
let rel = file
.strip_prefix(prefix.as_str())
.with_context(|| format!("'{file}' is not under '{prefix}'"))?;
let target = dest.join(rel);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let asset =
PluginAssets::get(file).with_context(|| format!("Embedded file '{file}' not found"))?;
fs::write(&target, &asset.data)
.with_context(|| format!("Failed to write {}", target.display()))?;
}
Ok(())
}
fn execute(
plan: &Plan,
catalog: &[Entry],
runner: &dyn Runner,
out: &mut dyn Write,
) -> anyhow::Result<()> {
let mut failed: Vec<String> = Vec::new();
for item in &plan.items {
match item {
AgentPlan::Claude { canonical, links } => {
writeln!(out, "agent claude (direct):")?;
for c in canonical {
let entry = catalog
.iter()
.find(|e| e.id == c.id)
.with_context(|| format!("Skill '{}' vanished from catalog", c.id))?;
materialise_canonical(entry, &c.dest)?;
writeln!(out, " refreshed {}", c.id)?;
}
for link in links {
let (id, dest, target) = match link {
Link::Create { id, dest, target } | Link::Relink { id, dest, target } => {
(id, dest, target)
}
Link::KeepForeign { id, dest, reason } => {
let _ = dest;
writeln!(out, " kept {id} ({})", foreign_reason(reason))?;
continue;
}
};
match classify_link(id, dest, target) {
Link::Create { .. } => {
write_link(dest, target)?;
writeln!(out, " linked {id}")?;
}
Link::Relink { .. } => {
write_link(dest, target)?;
writeln!(out, " relinked {id}")?;
}
Link::KeepForeign { reason, .. } => {
writeln!(out, " kept {id} ({})", foreign_reason(&reason))?;
}
}
}
}
AgentPlan::Delegate { agent, argv } => {
writeln!(out, "agent {agent} (delegate): npx {}", argv.join(" "))?;
if !runner.run("npx", argv)? {
failed.push(agent.clone());
}
}
}
}
if !failed.is_empty() {
bail!("npx skills failed for agent(s): {}", failed.join(", "));
}
Ok(())
}
fn print_plan(plan: &Plan, out: &mut dyn Write) -> io::Result<()> {
writeln!(out, "Project root: {}", plan.root.display())?;
writeln!(out)?;
for item in &plan.items {
match item {
AgentPlan::Claude { canonical, links } => {
writeln!(out, "agent claude (direct):")?;
for c in canonical {
writeln!(out, " refresh {} → {}", c.id, c.dest.display())?;
}
for link in links {
match link {
Link::Create { id, dest, target } => {
writeln!(
out,
" link {id} → {} ⇒ {}",
dest.display(),
target.display()
)?;
}
Link::Relink { id, dest, target } => {
writeln!(
out,
" relink {id} → {} ⇒ {}",
dest.display(),
target.display()
)?;
}
Link::KeepForeign { id, dest, reason } => {
writeln!(
out,
" keep {id} → {} ({})",
dest.display(),
foreign_reason(reason)
)?;
}
}
}
}
AgentPlan::Delegate { agent, argv } => {
writeln!(out, "agent {agent} (delegate):")?;
writeln!(out, " npx {}", argv.join(" "))?;
}
}
}
Ok(())
}
fn install_agents(
root: &Path,
global: bool,
dry_run: bool,
out: &mut dyn Write,
) -> anyhow::Result<()> {
let canon_dir = agent_canonical_dir(root, global)?;
let link_dir = claude_agents_dir(root, global)?;
let canon = canon_dir.join(DISPATCH_WORKER_AGENT_FILE);
let dest = link_dir.join(DISPATCH_WORKER_AGENT_FILE);
let target = relative_target(&link_dir, &canon_dir, DISPATCH_WORKER_AGENT_FILE);
writeln!(out, "agent claude (dispatch-worker):")?;
writeln!(
out,
" agent {DISPATCH_WORKER_AGENT_FILE} → {}",
dest.display()
)?;
if dry_run {
return Ok(());
}
let data = crate::install::embedded_asset(DISPATCH_WORKER_AGENT_ASSET)
.with_context(|| format!("Embedded agent def '{DISPATCH_WORKER_AGENT_ASSET}' not found"))?;
fs::create_dir_all(&canon_dir)
.with_context(|| format!("Failed to create {}", canon_dir.display()))?;
crate::fsutil::write_atomic(&canon, &data)?;
match classify_link(DISPATCH_WORKER_AGENT_FILE, &dest, &target) {
Link::Create { .. } => {
write_link(&dest, &target)?;
writeln!(out, " linked {DISPATCH_WORKER_AGENT_FILE}")?;
}
Link::Relink { .. } => {
write_link(&dest, &target)?;
writeln!(out, " relinked {DISPATCH_WORKER_AGENT_FILE}")?;
}
Link::KeepForeign { reason, .. } => {
writeln!(
out,
" kept {DISPATCH_WORKER_AGENT_FILE} ({})",
foreign_reason(&reason)
)?;
}
}
Ok(())
}
fn lexists(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok()
}
pub(crate) fn run_list(agent: Option<&str>, installed_only: bool) -> anyhow::Result<()> {
let catalog = discover()?;
let root = crate::root::find(None, &crate::root::default_markers())?;
let claude_present = matches!(agent.map(parse_agent), None | Some(Agent::Claude));
let dir = root.join(".claude/skills");
let mut out = io::stdout();
let mut domain = String::new();
for entry in &catalog {
let installed = lexists(&dir.join(&entry.id));
if installed_only && !installed {
continue;
}
if entry.domain != domain {
domain.clone_from(&entry.domain);
writeln!(out, "{domain}")?;
}
let status = if !claude_present {
"claude: n/a".to_string()
} else if installed {
"claude: installed".to_string()
} else {
"claude: —".to_string()
};
writeln!(
out,
" {:<16} {:<48} [{status}]",
entry.id, entry.description
)?;
}
Ok(())
}
pub(crate) struct InstallArgs<'a> {
pub(crate) agents: &'a [String],
pub(crate) skills: &'a [String],
pub(crate) domains: &'a [String],
pub(crate) only_memory: bool,
pub(crate) global: bool,
pub(crate) dry_run: bool,
pub(crate) yes: bool,
}
pub(crate) fn run_install(path: Option<PathBuf>, args: &InstallArgs<'_>) -> anyhow::Result<()> {
let catalog = discover()?;
let live: Vec<String> = PluginAssets::iter()
.map(|p| p.as_ref().to_string())
.collect();
let skills = resolve_install_ids(
args.only_memory,
args.skills,
live.iter().map(String::as_str),
MEMORY_SUBSET_DOMAIN,
)?;
validate_filters(&catalog, &skills, args.domains)?;
let root = crate::root::find(path, &crate::root::default_markers())?;
let agents = resolve_agents(args.agents, &root)?;
let plan = build_plan(&root, &agents, &catalog, &skills, args.domains, args.global)?;
let mut out = io::stdout();
print_plan(&plan, &mut out)?;
if args.dry_run {
return Ok(());
}
if !args.yes && !crate::install::prompt_confirm("\nProceed? [y/N] ")? {
writeln!(out, "Aborted.")?;
return Ok(());
}
crate::install::ensure_gitignored(&install_base(&root, args.global)?, ".doctrine/skills/*")?;
execute(&plan, &catalog, &Npx, &mut out)?;
if agents.iter().any(|a| matches!(a, Agent::Claude)) {
let base = install_base(&root, args.global)?;
crate::install::ensure_gitignored(&base, ".doctrine/agents/*")?;
crate::install::ensure_gitignored(&base, "!.doctrine/agents/AGENTS.md")?;
install_agents(&root, args.global, args.dry_run, &mut out)?;
if !args.global {
let exec = std::env::current_exe()
.context("Failed to resolve the doctrine executable path")?;
let outcome = crate::boot::install_claude_hook(
&root,
&crate::boot::HookSpec::stamp_subagent(&exec),
args.dry_run,
)?;
writeln!(out, "subagent hook: {}", hook_outcome_label(&outcome))?;
}
}
writeln!(out, "Done.")?;
Ok(())
}
fn hook_outcome_label(outcome: &crate::boot::RefreshOutcome) -> &'static str {
use crate::boot::RefreshOutcome::{None, PrintedFallback, Refreshed, Wired};
match outcome {
Wired(_) => "wired",
Refreshed(_) => "refreshed",
None => "already current",
PrintedFallback => "could not merge (settings left untouched)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn dedup_skills_route_not_restate() {
let named = [
"record-memory",
"retrieve-memory",
"spec-product",
"spec-tech",
"execute",
"phase-plan",
"canon",
"inquisition",
];
let banned = [
"--status in_progress",
"--status completed",
"--kind functional|quality",
"--type <type>",
"--path-scope <file>",
"--command \"<tok>\"",
];
for skill in named {
let path = format!("doctrine/skills/{skill}/SKILL.md");
let asset = PluginAssets::get(&path).expect("named skill must be embedded");
let text = std::str::from_utf8(&asset.data).expect("utf8");
for frag in banned {
assert!(
!text.contains(frag),
"restate-line: {skill} reproduces flag syntax `{frag}`"
);
}
assert!(
text.contains("using-doctrine") || text.contains("--help"),
"reachability: {skill} must point at a tier-1/2 reference"
);
}
}
fn entry(domain: &str, id: &str) -> Entry {
Entry {
domain: domain.to_string(),
id: id.to_string(),
description: format!("{id} desc"),
files: vec![format!("{domain}/skills/{id}/SKILL.md")],
}
}
#[test]
fn parse_meta_extracts_name_and_description() {
let md = "---\nname: code-review\ndescription: Review a diff.\n---\n\n# body\n";
let meta = parse_meta(md).unwrap();
assert_eq!(meta.name, "code-review");
assert_eq!(meta.description, "Review a diff.");
}
#[test]
fn parse_meta_rejects_missing_frontmatter() {
assert!(parse_meta("# no frontmatter\n").is_err());
}
#[test]
fn discover_finds_embedded_sample_skill() {
let cat = discover().unwrap();
let cr = cat.iter().find(|e| e.id == "code-review").unwrap();
assert_eq!(cr.domain, "doctrine");
assert!(!cr.description.is_empty());
assert!(cr.files.iter().any(|f| f.ends_with("SKILL.md")));
}
#[test]
fn discover_excludes_marketplace_only_domains() {
let cat = discover().unwrap();
assert!(cat.iter().all(|e| e.domain != "doctrine-memory"));
assert!(cat.iter().all(|e| e.domain != "doctrine-partner"));
assert!(
cat.iter()
.any(|e| e.id == "record-memory" && e.domain == "doctrine")
);
assert!(cat.iter().any(|e| e.id == "pair" && e.domain == "doctrine"));
assert!(
cat.iter()
.any(|e| e.id == "walkthrough" && e.domain == "doctrine")
);
}
#[test]
fn select_filters_by_id_and_domain() {
let all = vec![entry("review", "code-review"), entry("rust", "clippy")];
assert_eq!(select(&all, &["clippy".into()], &[]).len(), 1);
assert_eq!(select(&all, &[], &["review".into()]).len(), 1);
assert_eq!(select(&all, &[], &[]).len(), 2);
}
#[test]
fn validate_filters_rejects_unknown() {
let all = vec![entry("review", "code-review")];
assert!(validate_filters(&all, &["nope".into()], &[]).is_err());
assert!(validate_filters(&all, &[], &["nope".into()]).is_err());
assert!(validate_filters(&all, &["code-review".into()], &["review".into()]).is_ok());
}
#[test]
fn subset_ids_extracts_only_the_named_domain() {
let paths = [
"doctrine-memory/skills/record-memory/SKILL.md",
"doctrine-memory/skills/retrieve-memory/SKILL.md",
"doctrine-memory/README.md",
"doctrine-memory/.claude-plugin/plugin.json",
"doctrine/skills/route/SKILL.md",
];
let ids = subset_ids(paths.iter().copied(), "doctrine-memory");
assert_eq!(
ids,
["record-memory".to_string(), "retrieve-memory".to_string()]
.into_iter()
.collect()
);
}
#[test]
fn subset_ids_absent_domain_is_empty() {
let paths = ["doctrine/skills/route/SKILL.md"];
assert!(subset_ids(paths.iter().copied(), "doctrine-memory").is_empty());
}
#[test]
fn resolve_install_ids_passes_skills_through_when_not_only_memory() {
let got = resolve_install_ids(
false,
&["foo".into()],
std::iter::empty(),
MEMORY_SUBSET_DOMAIN,
)
.unwrap();
assert_eq!(got, vec!["foo".to_string()]);
}
#[test]
fn resolve_install_ids_derives_the_subset_when_only_memory() {
let paths = [
"doctrine-memory/skills/record-memory/SKILL.md",
"doctrine-memory/skills/retrieve-memory/SKILL.md",
];
let got = resolve_install_ids(true, &[], paths.iter().copied(), "doctrine-memory").unwrap();
assert_eq!(
got,
vec!["record-memory".to_string(), "retrieve-memory".to_string()]
);
}
#[test]
fn resolve_install_ids_bails_on_empty_derivation() {
let paths = ["doctrine/skills/route/SKILL.md"];
assert!(resolve_install_ids(true, &[], paths.iter().copied(), "doctrine-memory").is_err());
}
#[test]
fn resolve_install_ids_live_embed_yields_the_memory_pair() {
let live: Vec<String> = PluginAssets::iter()
.map(|p| p.as_ref().to_string())
.collect();
let got = resolve_install_ids(
true,
&[],
live.iter().map(String::as_str),
MEMORY_SUBSET_DOMAIN,
)
.unwrap();
assert_eq!(
got,
vec!["record-memory".to_string(), "retrieve-memory".to_string()]
);
}
#[test]
fn only_memory_selects_exactly_the_two_canonical_skills() {
let catalog = discover().unwrap();
let live: Vec<String> = PluginAssets::iter()
.map(|p| p.as_ref().to_string())
.collect();
let ids = resolve_install_ids(
true,
&[],
live.iter().map(String::as_str),
MEMORY_SUBSET_DOMAIN,
)
.unwrap();
validate_filters(&catalog, &ids, &[]).unwrap();
let selected = select(&catalog, &ids, &[]);
let got: BTreeSet<&str> = selected.iter().map(|e| e.id.as_str()).collect();
assert_eq!(
got,
["record-memory", "retrieve-memory"].into_iter().collect()
);
assert!(selected.iter().all(|e| e.domain == "doctrine"));
}
#[test]
fn claude_links_creates_then_relinks_an_owned_link() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let agent_dir = dir.path().join(".claude/skills");
let canon_dir = dir.path().join(".doctrine/skills");
fs::create_dir_all(&agent_dir).unwrap();
let e = entry("review", "code-review");
let sel = vec![&e];
let links = claude_links(&sel, &agent_dir, &canon_dir);
assert!(matches!(
links.as_slice(),
[Link::Create { target, .. }]
if target == &PathBuf::from("../../.doctrine/skills/code-review")
));
symlink(
"../../.doctrine/skills/code-review",
agent_dir.join("code-review"),
)
.unwrap();
let links = claude_links(&sel, &agent_dir, &canon_dir);
assert!(matches!(links.as_slice(), [Link::Relink { .. }]));
}
fn code_review_entry() -> Entry {
discover()
.unwrap()
.into_iter()
.find(|e| e.id == "code-review")
.unwrap()
}
#[test]
fn materialise_overwrites_stale_canonical() {
let dir = tempfile::tempdir().unwrap();
let e = code_review_entry();
let id_dir = dir.path().join(&e.id);
fs::create_dir_all(&id_dir).unwrap();
fs::write(id_dir.join("STALE.md"), "old").unwrap();
materialise_canonical(&e, &id_dir).unwrap();
assert!(!id_dir.join("STALE.md").exists(), "stale file must be gone");
let embed = PluginAssets::get("doctrine/skills/code-review/SKILL.md").unwrap();
let got = fs::read(id_dir.join("SKILL.md")).unwrap();
assert_eq!(got, embed.data.as_ref());
assert!(!dir.path().join(format!(".tmp-{}", e.id)).exists());
}
#[test]
fn materialise_heals_an_interrupted_stage() {
let dir = tempfile::tempdir().unwrap();
let e = code_review_entry();
let id_dir = dir.path().join(&e.id);
let tmp = dir.path().join(format!(".tmp-{}", e.id));
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("JUNK.md"), "partial").unwrap();
fs::create_dir_all(&id_dir).unwrap();
fs::write(id_dir.join("SKILL.md"), "prior").unwrap();
materialise_canonical(&e, &id_dir).unwrap();
assert!(!tmp.exists(), "leftover temp must be cleared");
assert!(!id_dir.join("JUNK.md").exists());
let embed = PluginAssets::get("doctrine/skills/code-review/SKILL.md").unwrap();
assert_eq!(
fs::read(id_dir.join("SKILL.md")).unwrap(),
embed.data.as_ref()
);
}
#[test]
fn materialise_clears_a_dangling_temp_leftover() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let e = code_review_entry();
let id_dir = dir.path().join(&e.id);
let tmp = dir.path().join(format!(".tmp-{}", e.id));
symlink("/no/such/target", &tmp).unwrap();
materialise_canonical(&e, &id_dir).unwrap();
assert!(
fs::symlink_metadata(&tmp).is_err(),
"dangling temp leftover must be cleared"
);
assert_eq!(fs::read(id_dir.join("SKILL.md")).unwrap(), embed_skill_md());
}
#[test]
fn canonical_dir_is_project_local_or_home() {
let root = Path::new("/proj");
assert_eq!(
canonical_dir(root, false).unwrap(),
Path::new("/proj/.doctrine/skills")
);
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
assert_eq!(
canonical_dir(root, true).unwrap(),
home.join(".doctrine/skills")
);
}
#[test]
fn install_base_anchors_both_trees_and_the_ignore() {
let root = Path::new("/proj");
assert_eq!(install_base(root, false).unwrap(), root);
assert_eq!(
canonical_dir(root, false).unwrap(),
install_base(root, false).unwrap().join(".doctrine/skills")
);
assert_eq!(
claude_dir(root, false).unwrap(),
install_base(root, false).unwrap().join(".claude/skills")
);
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
assert_eq!(install_base(root, true).unwrap(), home);
assert_eq!(
canonical_dir(root, true).unwrap(),
install_base(root, true).unwrap().join(".doctrine/skills")
);
}
#[test]
fn relative_target_is_computed_from_the_two_dirs() {
let agent = Path::new("/proj/.claude/skills");
let canon = Path::new("/proj/.doctrine/skills");
assert_eq!(
relative_target(agent, canon, "code-review"),
PathBuf::from("../../.doctrine/skills/code-review")
);
let g_agent = Path::new("/home/u/.claude/skills");
let g_canon = Path::new("/home/u/.doctrine/skills");
assert_eq!(
relative_target(g_agent, g_canon, "code-review"),
PathBuf::from("../../.doctrine/skills/code-review")
);
}
#[test]
fn classify_link_covers_the_ownership_trichotomy() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = PathBuf::from("../../.doctrine/skills/code-review");
let missing = dir.path().join("missing");
assert!(matches!(
classify_link("code-review", &missing, &target),
Link::Create { .. }
));
let ours = dir.path().join("ours");
symlink(&target, &ours).unwrap();
assert!(matches!(
classify_link("code-review", &ours, &target),
Link::Relink { .. }
));
let foreign = dir.path().join("foreign");
symlink("somewhere/else", &foreign).unwrap();
match classify_link("code-review", &foreign, &target) {
Link::KeepForeign {
reason: ForeignReason::ForeignSymlink(where_),
..
} => assert_eq!(where_, PathBuf::from("somewhere/else")),
other => panic!("expected foreign-symlink, got {other:?}"),
}
let real = dir.path().join("real");
fs::create_dir_all(&real).unwrap();
assert!(matches!(
classify_link("code-review", &real, &target),
Link::KeepForeign {
reason: ForeignReason::RealDir,
..
}
));
}
#[test]
fn delegate_argv_all_skills_omits_skill_flags() {
let e = entry("review", "code-review");
let argv = delegate_argv("codex", &[&e], false, false);
assert_eq!(
argv,
vec![
"skills",
"add",
"davidlee/doctrine",
"--agent",
"codex",
"--yes"
]
);
}
#[test]
fn delegate_argv_subset_and_global() {
let e = entry("review", "code-review");
let argv = delegate_argv("cursor", &[&e], true, true);
assert_eq!(
argv,
vec![
"skills",
"add",
"davidlee/doctrine",
"--agent",
"cursor",
"--global",
"--skill",
"code-review",
"--yes",
]
);
}
#[test]
fn resolve_agents_explicit() {
let dir = tempfile::tempdir().unwrap();
let agents = resolve_agents(&["claude".into(), "codex".into()], dir.path()).unwrap();
assert_eq!(agents, vec![Agent::Claude, Agent::Other("codex".into())]);
}
#[test]
fn resolve_agents_detects_claude_dir() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".claude")).unwrap();
assert_eq!(
resolve_agents(&[], dir.path()).unwrap(),
vec![Agent::Claude]
);
}
#[test]
fn resolve_agents_errors_without_target() {
let dir = tempfile::tempdir().unwrap();
assert!(resolve_agents(&[], dir.path()).is_err());
}
#[test]
fn build_plan_routes_claude_direct_and_others_delegate() {
let dir = tempfile::tempdir().unwrap();
let all = vec![entry("review", "code-review")];
let plan = build_plan(
dir.path(),
&[Agent::Claude, Agent::Other("codex".into())],
&all,
&[],
&[],
false,
)
.unwrap();
assert!(matches!(plan.items.first(), Some(AgentPlan::Claude { .. })));
assert!(matches!(
plan.items.get(1),
Some(AgentPlan::Delegate { agent, .. }) if agent == "codex"
));
}
#[derive(Debug, Default)]
struct FakeRunner {
calls: RefCell<Vec<Vec<String>>>,
ok: bool,
}
impl Runner for FakeRunner {
fn run(&self, _program: &str, args: &[String]) -> anyhow::Result<bool> {
self.calls.borrow_mut().push(args.to_vec());
Ok(self.ok)
}
}
fn run_claude(root: &Path) -> String {
let catalog = discover().unwrap();
let plan = build_plan(
root,
&[Agent::Claude],
&catalog,
&["code-review".into()],
&[],
false,
)
.unwrap();
let runner = FakeRunner {
ok: true,
..FakeRunner::default()
};
let mut out = Vec::new();
execute(&plan, &catalog, &runner, &mut out).unwrap();
assert!(runner.calls.borrow().is_empty(), "no npx for Claude");
String::from_utf8(out).unwrap()
}
fn embed_skill_md() -> Vec<u8> {
PluginAssets::get("doctrine/skills/code-review/SKILL.md")
.unwrap()
.data
.to_vec()
}
#[test]
fn execute_creates_link_resolving_to_canonical() {
let dir = tempfile::tempdir().unwrap();
let log = run_claude(dir.path());
let link = dir.path().join(".claude/skills/code-review");
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read(link.join("SKILL.md")).unwrap(), embed_skill_md());
assert!(
dir.path()
.join(".doctrine/skills/code-review/SKILL.md")
.is_file()
);
assert!(log.contains("refreshed code-review"));
assert!(log.contains("linked code-review"));
}
#[test]
fn execute_relink_heals_a_dangling_owned_link() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let agent_dir = dir.path().join(".claude/skills");
fs::create_dir_all(&agent_dir).unwrap();
symlink(
"../../.doctrine/skills/code-review",
agent_dir.join("code-review"),
)
.unwrap();
assert!(
!agent_dir.join("code-review").exists(),
"dangling pre-state"
);
let log = run_claude(dir.path());
assert_eq!(
fs::read(agent_dir.join("code-review/SKILL.md")).unwrap(),
embed_skill_md()
);
assert!(log.contains("relinked code-review"));
}
#[test]
fn execute_keeps_a_foreign_real_dir() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join(".claude/skills/code-review");
fs::create_dir_all(&real).unwrap();
fs::write(real.join("MINE.md"), "pinned").unwrap();
let log = run_claude(dir.path());
assert!(
!fs::symlink_metadata(&real)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(real.join("MINE.md")).unwrap(), "pinned");
assert!(log.contains("kept code-review (real dir)"));
}
#[test]
fn lexists_reports_a_dangling_managed_link_as_installed() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("code-review");
symlink("../../.doctrine/skills/code-review", &link).unwrap();
assert!(!link.exists(), "exists() follows the link → hidden");
assert!(lexists(&link), "lexists sees the link → installed (F5)");
}
#[test]
fn execute_keeps_a_foreign_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let agent_dir = dir.path().join(".claude/skills");
fs::create_dir_all(&agent_dir).unwrap();
symlink("/some/other/place", agent_dir.join("code-review")).unwrap();
let log = run_claude(dir.path());
assert_eq!(
fs::read_link(agent_dir.join("code-review")).unwrap(),
PathBuf::from("/some/other/place")
);
assert!(log.contains("kept code-review (foreign symlink → /some/other/place)"));
}
#[test]
fn execute_re_keeps_a_dest_that_turned_foreign_after_planning() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let catalog = discover().unwrap();
let plan = build_plan(
dir.path(),
&[Agent::Claude],
&catalog,
&["code-review".into()],
&[],
false,
)
.unwrap();
let agent_dir = dir.path().join(".claude/skills");
fs::create_dir_all(&agent_dir).unwrap();
symlink("/some/other/place", agent_dir.join("code-review")).unwrap();
let runner = FakeRunner {
ok: true,
..FakeRunner::default()
};
let mut out = Vec::new();
execute(&plan, &catalog, &runner, &mut out).unwrap();
let log = String::from_utf8(out).unwrap();
assert!(
log.contains("kept code-review (foreign symlink → /some/other/place)"),
"a dest that turned foreign after planning must be kept: {log}"
);
assert_eq!(
fs::read_link(agent_dir.join("code-review")).unwrap(),
PathBuf::from("/some/other/place"),
"the foreign symlink is untouched"
);
}
#[test]
fn execute_delegates_with_expected_argv() {
let dir = tempfile::tempdir().unwrap();
let catalog = discover().unwrap();
let plan = build_plan(
dir.path(),
&[Agent::Other("codex".into())],
&catalog,
&[],
&[],
false,
)
.unwrap();
let runner = FakeRunner {
ok: true,
..FakeRunner::default()
};
let mut out = Vec::new();
execute(&plan, &catalog, &runner, &mut out).unwrap();
let calls = runner.calls.borrow();
assert_eq!(calls.len(), 1);
let first = calls.first().unwrap();
assert_eq!(first.first().map(String::as_str), Some("skills"));
assert!(first.iter().any(|a| a == "codex"));
}
#[test]
fn run_install_self_enforces_the_skills_gitignore() {
let dir = tempfile::tempdir().unwrap();
run_install(
Some(dir.path().to_path_buf()),
&InstallArgs {
agents: &["claude".into()],
skills: &["code-review".into()],
domains: &[],
only_memory: false,
global: false,
dry_run: false,
yes: true,
},
)
.unwrap();
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gi.contains(".doctrine/skills/*"),
"skills install must self-enforce the derived-tree ignore"
);
let star = gi.find(".doctrine/agents/*");
let keep = gi.find("!.doctrine/agents/AGENTS.md");
assert!(star.is_some(), "agents install must ignore derived agents");
assert!(
keep.is_some(),
"agents install must re-include the authored AGENTS.md"
);
assert!(star < keep, "the `*` exclude must precede its negation");
}
#[test]
fn execute_reports_delegate_failure() {
let dir = tempfile::tempdir().unwrap();
let catalog = discover().unwrap();
let plan = build_plan(
dir.path(),
&[Agent::Other("codex".into())],
&catalog,
&[],
&[],
false,
)
.unwrap();
let runner = FakeRunner {
ok: false,
..FakeRunner::default()
};
let mut out = Vec::new();
assert!(execute(&plan, &catalog, &runner, &mut out).is_err());
}
}