#![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 std::process::Command;
use anyhow::{Context, bail};
use rust_embed::RustEmbed;
use serde::Deserialize;
use crate::memory::MemoryType;
#[derive(RustEmbed)]
#[folder = "install/"]
struct Assets;
#[derive(RustEmbed)]
#[folder = "plugins/"]
struct PluginAssets;
const MEMORY_SUBSET_DOMAIN: &str = "doctrine-memory";
const PARTNER_SUBSET_DOMAIN: &str = "doctrine-partner";
const DOCTRINE_MARKETPLACE: &str = "doctrine";
const MARKETPLACE_ONLY_DOMAINS: &[&str] = &[MEMORY_SUBSET_DOMAIN, PARTNER_SUBSET_DOMAIN];
const RUNNER_BUNX: &str = "bunx";
const RUNNER_NPX: &str = "npx";
const DISPATCH_WORKER_AGENT_FILE: &str = "dispatch-worker.md";
const DISPATCH_WORKER_AGENT_ASSET: &str = "agents/claude/dispatch-worker.md";
const DISPATCH_WORKER_AGENT_ASSET_PI: &str = "agents/pi/dispatch-worker.md";
pub(crate) const WORKER_RESOLVE_MARKER: &str = "{{ prompt resolve --role worker }}";
pub(crate) fn embedded_asset(rel: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
Assets::get(rel).map(|f| f.data)
}
#[derive(Debug, Deserialize)]
struct Manifest {
#[serde(default = "default_target")]
target: String,
#[serde(default)]
dirs: DirsSection,
#[serde(default)]
gitignore: GitignoreSection,
#[serde(default)]
root_markers: RootMarkersSection,
#[serde(default)]
memory: MemorySection,
#[serde(default)]
hymns: HymnsSection,
}
fn default_target() -> String {
".doctrine".to_string()
}
#[derive(Debug, Default, Deserialize)]
struct DirsSection {
#[serde(default)]
create: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct GitignoreSection {
#[serde(default)]
entries: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RootMarkersSection {
#[serde(default = "crate::root::default_markers")]
markers: Vec<String>,
}
impl Default for RootMarkersSection {
fn default() -> Self {
Self {
markers: crate::root::default_markers(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct MemorySection {
#[serde(default)]
seed_items: Vec<SeedItem>,
}
#[derive(Debug, Default, Deserialize)]
struct HymnsSection {
#[serde(default)]
seal: Vec<String>,
#[serde(default)]
expose: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SeedItem {
key: String,
#[serde(rename = "type")]
memory_type: String,
title: String,
body_template: String,
#[serde(default)]
summary: String,
}
#[derive(Debug, PartialEq, Eq)]
enum Step {
CreateDir(PathBuf),
Install { source: String, dest: PathBuf },
Skip { source: String, dest: PathBuf },
Gitignore { entry: String, dest: PathBuf },
}
#[derive(Debug)]
struct Plan {
project_root: PathBuf,
target_dir: PathBuf,
steps: Vec<Step>,
}
pub(crate) struct InstallArgs<'a> {
pub(crate) agents: &'a [String],
pub(crate) skills: &'a [String],
pub(crate) domains: &'a [String],
#[expect(
dead_code,
reason = "wired in PHASE-03 (--only-memory subset derivation)"
)]
pub(crate) only_memory: bool,
pub(crate) global: bool,
pub(crate) dry_run: bool,
pub(crate) yes: bool,
pub(crate) dev: bool,
}
pub(crate) fn run(project_path: Option<PathBuf>, args: &InstallArgs<'_>) -> anyhow::Result<()> {
let manifest = load_manifest()?;
let project_root =
detect_project_root(project_path, &manifest).context("Could not find project root")?;
let plan = build_plan(&manifest, &project_root);
print_plan(&plan)?;
if args.dry_run {
print_forward_summary(&project_root, args)?;
return Ok(());
}
if !args.yes && !prompt_confirm("\nProceed? [y/N] ")? {
stdout_line("Aborted.")?;
return Ok(());
}
execute_plan(&plan)?;
seed_authoring_memories(&project_root, &manifest)?;
stdout_line("Done.")?;
let exec = crate::boot::resolve_exec()?;
run_forward_steps(&project_root, &exec, args)?;
Ok(())
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "retained for standalone install paths")
)]
fn sync_hint() -> &'static str {
"Next: run `doctrine memory sync` to materialize the global memory corpus."
}
fn seed_authoring_memories(root: &Path, manifest: &Manifest) -> anyhow::Result<()> {
if manifest.memory.seed_items.is_empty() {
return Ok(());
}
let mut stdout = io::stdout();
writeln!(stdout, " seeding authoring memories…")?;
for item in &manifest.memory.seed_items {
let memory_type = MemoryType::parse(&item.memory_type)
.with_context(|| format!("invalid memory type {:?}", item.memory_type))?;
let body = asset_text(&item.body_template)
.with_context(|| format!("seed body template '{}' not found", item.body_template))?;
if crate::memory::seed_by_key(
root,
&item.key,
memory_type,
&item.title,
&body,
&item.summary,
)? {
writeln!(
stdout,
" seed memory {} → memory/items/{}/",
item.key, item.key
)?;
} else {
writeln!(stdout, " skip seed {} (exists)", item.key)?;
}
}
Ok(())
}
fn print_forward_summary(root: &Path, args: &InstallArgs<'_>) -> anyhow::Result<()> {
let agents = detect_agents(args.agents, root);
let harnesses = crate::boot::resolve_harnesses(&[], root).unwrap_or_default();
let mut stdout = io::stdout();
if args.dry_run {
writeln!(stdout, "Forward steps (not executed under --dry-run):")?;
} else {
writeln!(stdout, "Base install complete. Forward steps:")?;
}
writeln!(stdout)?;
writeln!(
stdout,
" {:<12} materialize shipped corpus into .doctrine/memory/shipped/",
"memory sync"
)?;
if harnesses.is_empty() {
writeln!(
stdout,
" {:<12} (no harness directories detected — skipped)",
"boot"
)?;
} else {
let labels: Vec<&str> = harnesses.iter().map(crate::boot::harness_label).collect();
writeln!(
stdout,
" {:<12} wire @-import + session hooks for {}",
"boot",
labels.join(", ")
)?;
}
if agents.is_empty() {
writeln!(
stdout,
" {:<12} (no agents detected or specified — skipped)",
"skills"
)?;
} else {
for agent in &agents {
if agent == "claude" {
writeln!(
stdout,
" {:<12} register marketplace + install plugin + agent def for claude",
"claude"
)?;
} else {
writeln!(
stdout,
" {:<12} install skills for {agent} (delegates to npx)",
"skills"
)?;
}
}
}
Ok(())
}
fn run_forward_steps(root: &Path, exec: &Path, args: &InstallArgs<'_>) -> anyhow::Result<()> {
let agents = detect_agents(args.agents, root);
let harnesses = crate::boot::resolve_harnesses(&[], root).unwrap_or_default();
print_forward_summary(root, args)?;
let mut all_yes = false;
if prompt_step(
"Materialize shipped memory corpus? [y/N/a]",
args.yes,
&mut all_yes,
)? {
match crate::corpus::sync_corpus(root, &crate::corpus::embedded_assets(), false) {
Ok(report) => {
let mut out = io::stdout();
writeln!(
out,
" corpus sync: {} new, {} changed, {} unchanged, {} prune",
report.plan.new.len(),
report.plan.changed.len(),
report.plan.unchanged.len(),
report.plan.prune.len(),
)?;
}
Err(e) => {
writeln!(io::stdout(), " memory sync failed: {e:#}")?;
}
}
}
#[expect(
clippy::collapsible_if,
reason = "let-else chain is clearer than && let"
)]
if !harnesses.is_empty()
&& prompt_step(
"Wire @-import + session hooks for detected harnesses? [y/N/a]",
args.yes,
&mut all_yes,
)?
{
if let Err(e) = crate::boot::wire(root, exec, &harnesses, false) {
writeln!(io::stdout(), " boot wire failed: {e:#}")?;
}
}
if prompt_step(
"Project exposed hymn starters? [y/N/a]",
args.yes,
&mut all_yes,
)? {
match (embedded_expose_set(), embedded_seal_set()) {
(Ok(expose), Ok(seal)) => {
let disk = root.join(".doctrine").join(HYMNS_DIRNAME);
let embedded = embedded_hymns();
match project_starters(&disk, &embedded, &seal, &expose.0, args.dry_run) {
Ok(written) => {
writeln!(io::stdout(), " projected {} hymn file(s)", written.len())?;
}
Err(e) => {
writeln!(io::stdout(), " hymn projection failed: {e:#}")?;
}
}
}
(Err(e), _) | (_, Err(e)) => {
writeln!(io::stdout(), " hymn projection failed: {e:#}")?;
}
}
}
let catalog = discover()?;
let selected = select_for_install(&catalog, args.skills, args.domains)?;
let (runner_name, runner) = resolve_runner();
let repo = &crate::dtoml::load_doctrine_toml(root)?.install.repo;
let mut non_claude_agents: Vec<String> = Vec::new();
let mut skipped_marketplace: Option<String> = None;
let mut skipped_plugin: Option<String> = None;
for agent in &agents {
let question: String = if agent == "claude" {
"Install skills + agent def for claude? [y/N/a]".to_string()
} else {
format!("Install skills for {agent} (delegates to npx)? [y/N/a]")
};
if !prompt_step(&question, args.yes, &mut all_yes)? {
continue;
}
if agent == "claude" {
let mut out = io::stdout();
let cwd = std::env::current_dir().context("failed to read current directory")?;
let source = select_marketplace_source(root, &cwd, repo, args.dev)?;
let source_arg = source.as_arg();
let key = enable_key();
let registered = claude_cmd_stdout(&["plugin", "marketplace", "list"])
.and_then(|o| parse_registered_source(&o, DOCTRINE_MARKETPLACE));
let action = marketplace_action(registered, &source);
if action != MarketplaceAction::Skip {
let verb = if action == MarketplaceAction::Refresh {
"refresh"
} else {
"add"
};
if prompt_step(
&format!("claude plugin marketplace {verb} {source_arg}? [y/N/a]"),
args.yes,
&mut all_yes,
)? {
match claude_plugin_add_marketplace(&source_arg) {
Ok(()) => writeln!(out, " marketplace {source_arg} registered")?,
Err(e) => {
if refresh_failure_is_fatal(&action) {
return Err(e.context(format!(
"marketplace refresh to {source_arg} failed — aborting; \
the previously registered doctrine source is stale"
)));
}
writeln!(out, " marketplace add failed: {e:#}")?;
skipped_marketplace = Some(source_arg.to_string());
}
}
} else {
skipped_marketplace = Some(source_arg.to_string());
}
}
if !claude_plugin_has(&key) {
if prompt_step(
&format!("claude plugin install {key} --scope project? [y/N/a]"),
args.yes,
&mut all_yes,
)? {
match claude_plugin_install(&key) {
Ok(()) => writeln!(out, " {key} plugin installed")?,
Err(e) => {
writeln!(out, " plugin install failed: {e:#}")?;
skipped_plugin = Some(key.clone());
}
}
} else {
skipped_plugin = Some(key.clone());
}
}
if let Err(e) = install_agents_for(root, "claude", None, args.global, false, &mut out) {
writeln!(io::stdout(), " claude agent-def install failed: {e:#}")?;
}
} else {
non_claude_agents.push(agent.clone());
if let Err(e) = install_agents_for(
root,
agent,
Some(agent),
args.global,
false,
&mut io::stdout(),
) {
writeln!(io::stdout(), " {agent} agent-def install failed: {e:#}")?;
}
}
}
if !non_claude_agents.is_empty() {
let mut out = io::stdout();
if let Err(e) = install_for_other(
&InstallOtherArgs {
agent_names: &non_claude_agents,
selected: &selected,
global: args.global,
repo,
runner: &runner,
runner_name,
},
&mut out,
) {
writeln!(io::stdout(), " non-Claude skills install failed: {e:#}")?;
}
}
if skipped_marketplace.is_some() || skipped_plugin.is_some() {
writeln!(io::stdout())?;
writeln!(
io::stdout(),
"Claude Code requires the doctrine plugin. To install:"
)?;
if let Some(source_arg) = &skipped_marketplace {
writeln!(io::stdout(), " claude plugin marketplace add {source_arg}")?;
}
if let Some(key) = &skipped_plugin {
writeln!(
io::stdout(),
" claude plugin install {key} --scope project"
)?;
}
}
Ok(())
}
fn claude_plugin_add_marketplace(repo: &str) -> anyhow::Result<()> {
let status = Command::new("claude")
.args(["plugin", "marketplace", "add", repo])
.status()
.context("failed to execute claude plugin marketplace add")?;
anyhow::ensure!(
status.success(),
"claude plugin marketplace add exited with {status}"
);
Ok(())
}
fn claude_plugin_install(name: &str) -> anyhow::Result<()> {
let status = Command::new("claude")
.args(["plugin", "install", name, "--scope", "project"])
.status()
.context("failed to execute claude plugin install")?;
anyhow::ensure!(
status.success(),
"claude plugin install exited with {status}"
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MarketplaceSource {
Github(String),
Directory(PathBuf),
}
impl MarketplaceSource {
fn as_arg(&self) -> std::borrow::Cow<'_, str> {
match self {
MarketplaceSource::Github(slug) => std::borrow::Cow::Borrowed(slug),
MarketplaceSource::Directory(path) => path.to_string_lossy(),
}
}
}
#[derive(Debug, Deserialize)]
struct MarketplaceManifest {
name: String,
#[serde(default)]
plugins: Vec<ManifestPlugin>,
}
#[derive(Debug, Deserialize)]
struct ManifestPlugin {
name: String,
}
fn select_plugin(manifest: &MarketplaceManifest) -> Option<&str> {
manifest
.plugins
.iter()
.map(|p| p.name.as_str())
.find(|name| *name == manifest.name)
}
fn enable_key() -> String {
format!("{DOCTRINE_MARKETPLACE}@{DOCTRINE_MARKETPLACE}")
}
const MARKETPLACE_MANIFEST_REL: &str = ".claude-plugin/marketplace.json";
fn select_marketplace_source(
root: &Path,
cwd: &Path,
repo: &str,
dev: bool,
) -> anyhow::Result<MarketplaceSource> {
if !dev {
return Ok(MarketplaceSource::Github(repo.to_string()));
}
let joined = if root.is_absolute() {
root.to_path_buf()
} else {
cwd.join(root)
};
let abs = fs::canonicalize(&joined).with_context(|| {
format!(
"--dev: could not resolve project root {} (does it exist?)",
joined.display()
)
})?;
let manifest_path = abs.join(MARKETPLACE_MANIFEST_REL);
let raw = fs::read_to_string(&manifest_path).with_context(|| {
format!(
"--dev requires a plugin marketplace manifest at {} — none found; \
run from doctrine's own repo or drop --dev for the github source",
manifest_path.display()
)
})?;
let manifest: MarketplaceManifest = serde_json::from_str(&raw).with_context(|| {
format!(
"--dev: malformed marketplace manifest {}",
manifest_path.display()
)
})?;
anyhow::ensure!(
select_plugin(&manifest).is_some(),
"--dev: {} does not define the `{DOCTRINE_MARKETPLACE}` plugin \
(marketplace `{}`) — not a doctrine marketplace",
manifest_path.display(),
manifest.name,
);
Ok(MarketplaceSource::Directory(abs))
}
fn claude_list_has(output: &str, key: &str) -> bool {
output.split_whitespace().any(|tok| tok == key)
}
fn claude_cmd_stdout(args: &[&str]) -> Option<String> {
Command::new("claude")
.args(args)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
}
fn claude_plugin_has(key: &str) -> bool {
claude_cmd_stdout(&["plugin", "list"]).is_some_and(|o| claude_list_has(&o, key))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RegisteredSource {
Directory(String),
Github(String),
}
fn parse_registered_source(list: &str, name: &str) -> Option<RegisteredSource> {
let mut current: Option<&str> = None;
for line in list.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("❯ ") {
current = Some(rest.trim());
continue;
}
if current == Some(name)
&& let Some(spec) = t.strip_prefix("Source:")
{
let (kind, rest) = spec.trim().split_once(' ')?;
let inner = rest.trim().strip_prefix('(')?.strip_suffix(')')?;
return match kind {
"Directory" => Some(RegisteredSource::Directory(inner.to_string())),
"GitHub" => Some(RegisteredSource::Github(inner.to_string())),
_ => None,
};
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MarketplaceAction {
Skip,
Add,
Refresh,
}
fn source_matches(registered: &RegisteredSource, intended: &MarketplaceSource) -> bool {
let arg = intended.as_arg();
match (registered, intended) {
(RegisteredSource::Directory(a), MarketplaceSource::Directory(_))
| (RegisteredSource::Github(a), MarketplaceSource::Github(_)) => a.as_str() == arg,
_ => false,
}
}
fn marketplace_action(
registered: Option<RegisteredSource>,
intended: &MarketplaceSource,
) -> MarketplaceAction {
match registered {
None => MarketplaceAction::Add,
Some(reg) if source_matches(®, intended) => MarketplaceAction::Skip,
Some(_) => MarketplaceAction::Refresh,
}
}
fn refresh_failure_is_fatal(action: &MarketplaceAction) -> bool {
matches!(action, MarketplaceAction::Refresh)
}
pub(crate) fn asset_text(name: &str) -> anyhow::Result<String> {
let file = Assets::get(name).with_context(|| format!("Embedded asset '{name}' is missing"))?;
let text = std::str::from_utf8(&file.data)
.with_context(|| format!("Embedded asset '{name}' is not valid UTF-8"))?;
Ok(text.to_string())
}
pub(crate) fn embedded_seal_set() -> anyhow::Result<crate::hymns::SealSet> {
let manifest = load_manifest()?;
let mut slots = std::collections::BTreeSet::new();
for s in &manifest.hymns.seal {
let slot = parse_seal_slot(s)?;
slots.insert(slot);
}
Ok(crate::hymns::SealSet(slots))
}
pub(crate) fn embedded_expose_set() -> anyhow::Result<crate::hymns::SealSet> {
let manifest = load_manifest()?;
let mut slots = std::collections::BTreeSet::new();
for s in &manifest.hymns.expose {
let slot = parse_seal_slot(s)?;
slots.insert(slot);
}
Ok(crate::hymns::SealSet(slots))
}
pub(crate) fn embedded_hymns() -> Vec<(String, Vec<u8>)> {
let prefix = "hymns/";
Assets::iter()
.filter_map(|name| {
let name = name.as_ref();
name.strip_prefix(prefix)
.map(|rel| (rel.to_string(), Assets::get(name).map(|f| f.data.to_vec())))
})
.filter_map(|(rel, opt)| opt.map(|bytes| (rel, bytes)))
.collect()
}
pub(crate) fn embedded_agent_defs() -> Vec<(String, Vec<u8>)> {
let prefix = "agents/";
Assets::iter()
.filter_map(|name| {
let name = name.as_ref();
name.strip_prefix(prefix)
.map(|rel| (rel.to_string(), Assets::get(name).map(|f| f.data.to_vec())))
})
.filter_map(|(rel, opt)| opt.map(|bytes| (rel, bytes)))
.collect()
}
pub(crate) const HYMNS_DIRNAME: &str = "hymns";
pub(crate) const KNOWN_STAGE_LABELS: &[&str] = &[
"route",
"canon",
"preflight",
"slice",
"design",
"inquisition",
"plan",
"phase-plan",
"execute",
"audit",
"reconcile",
"close",
"consult",
"walkthrough",
"notes",
"next",
"record-memory",
"retrieve-memory",
];
#[derive(Debug, serde::Deserialize, Default)]
struct Sidecar {
#[serde(default)]
harness: Option<String>,
#[serde(default)]
model: Option<Vec<String>>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
arm: Option<String>,
#[serde(default)]
stage: Option<String>,
#[serde(default)]
replaces: Option<String>,
}
pub(crate) fn parse_role(s: &str) -> anyhow::Result<crate::hymns::Role> {
match s {
"worker" => Ok(crate::hymns::Role::Worker),
"orchestrator" => Ok(crate::hymns::Role::Orchestrator),
other => bail!("unknown role {other:?}; expected 'worker' or 'orchestrator'"),
}
}
pub(crate) fn parse_arm(s: &str) -> anyhow::Result<crate::hymns::Arm> {
match s {
"subagent" => Ok(crate::hymns::Arm::Subagent),
"subprocess" => Ok(crate::hymns::Arm::Subprocess),
other => bail!("unknown arm {other:?}; expected 'subagent' or 'subprocess'"),
}
}
fn parse_slot_ref(s: &str) -> anyhow::Result<crate::hymns::Slot> {
let (band_seg, label) = s
.split_once('/')
.with_context(|| format!("slot ref {s:?}: expected 'band/label'"))?;
let band = crate::hymns::Band::from_segment(band_seg)
.with_context(|| format!("unknown band {band_seg:?}"))?;
Ok(crate::hymns::Slot::new(band, label))
}
fn path_to_slot(rel: &Path) -> anyhow::Result<crate::hymns::Slot> {
let first = rel
.components()
.next()
.and_then(|c| c.as_os_str().to_str())
.context("snippet path has no band segment")?;
let band = crate::hymns::Band::from_segment(first)
.with_context(|| format!("unknown band {first:?} in {:?}", rel.display()))?;
let label = {
let rest: PathBuf = rel.components().skip(1).collect();
let mut label = rest.to_string_lossy().into_owned();
if let Some(stripped) = label.strip_suffix(".md") {
label = stripped.to_string();
}
label
};
Ok(crate::hymns::Slot::new(band, label))
}
fn default_selector(slot: &crate::hymns::Slot) -> crate::hymns::Selector {
match slot.band {
crate::hymns::Band::Harness => crate::hymns::Selector {
harness: Some(slot.label.clone()),
..Default::default()
},
crate::hymns::Band::Role => {
let role = match slot.label.as_str() {
"worker" => crate::hymns::Role::Worker,
"orchestrator" => crate::hymns::Role::Orchestrator,
_ => return crate::hymns::Selector::default(),
};
crate::hymns::Selector {
role: Some(role),
..Default::default()
}
}
crate::hymns::Band::Model => crate::hymns::Selector {
model: BTreeSet::from([slot.label.clone()]),
..Default::default()
},
crate::hymns::Band::Stage => crate::hymns::Selector {
stage: Some(slot.label.clone()),
..Default::default()
},
crate::hymns::Band::Preamble | crate::hymns::Band::Project => {
crate::hymns::Selector::default()
}
}
}
fn overlay_selector(
base: &crate::hymns::Selector,
sidecar: &Sidecar,
) -> anyhow::Result<crate::hymns::Selector> {
let mut sel = base.clone();
if let Some(ref h) = sidecar.harness {
sel.harness = Some(h.clone());
}
if let Some(ref list) = sidecar.model {
sel.model = list.iter().cloned().collect();
}
if let Some(ref r) = sidecar.role {
sel.role = Some(parse_role(r)?);
}
if let Some(ref a) = sidecar.arm {
sel.arm = Some(parse_arm(a)?);
}
if let Some(ref s) = sidecar.stage {
sel.stage = Some(s.clone());
}
if let Some(ref replaces) = sidecar.replaces {
sel.replaces = Some(parse_slot_ref(replaces)?);
}
Ok(sel)
}
fn has_ext(rel_path: &str, ext: &str) -> bool {
Path::new(rel_path).extension().is_some_and(|e| e == ext)
}
fn collect_snippet_paths(root: &Path, current: &Path, paths: &mut BTreeSet<PathBuf>) {
let Ok(entries) = fs::read_dir(current) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
let path = entry.path();
if ft.is_dir() {
collect_snippet_paths(root, &path, paths);
} else {
let ext_ok = path
.extension()
.is_some_and(|ext| ext == "md" || ext == "toml");
if ext_ok && let Ok(rel) = path.strip_prefix(root) {
paths.insert(rel.to_path_buf());
}
}
}
}
fn load_embedded_corpus(
embedded: &[(String, Vec<u8>)],
) -> anyhow::Result<Vec<crate::hymns::Snippet>> {
let mut sidecars: std::collections::BTreeMap<String, Sidecar> =
std::collections::BTreeMap::new();
for (rel_path, bytes) in embedded {
if has_ext(rel_path, "toml") {
let stem = rel_path
.strip_suffix(".toml")
.context("strip_suffix for .toml checked above")?
.to_string();
let text = String::from_utf8(bytes.clone())
.map_err(|e| anyhow::anyhow!("embedded sidecar not UTF-8: {e}"))?;
let sc: Sidecar =
toml::from_str(&text).with_context(|| format!("invalid sidecar: {rel_path}"))?;
sidecars.insert(stem, sc);
}
}
let mut snippets = Vec::new();
for (rel_path, bytes) in embedded {
if has_ext(rel_path, "toml") {
continue;
}
if !has_ext(rel_path, "md") {
continue;
}
let rel = Path::new(rel_path);
if rel.components().count() < 2 {
continue;
}
let body = String::from_utf8(bytes.clone())
.map_err(|e| anyhow::anyhow!("{rel_path:?}: not valid UTF-8: {e}"))?;
let slot = path_to_slot(rel).with_context(|| format!("embedded snippet {rel_path:?}"))?;
let base_sel = default_selector(&slot);
let stem = rel_path
.strip_suffix(".md")
.context(".md suffix verified above")?;
let selector = if let Some(sc) = sidecars.get(stem) {
overlay_selector(&base_sel, sc)
.with_context(|| format!("embedded sidecar for {rel_path:?}"))?
} else {
base_sel
};
snippets.push(crate::hymns::Snippet {
slot,
selector,
provenance: crate::hymns::Provenance::Framework,
body,
});
}
Ok(snippets)
}
fn load_disk_corpus(
disk_root: &Path,
sealed: &crate::hymns::SealSet,
) -> anyhow::Result<Vec<crate::hymns::Snippet>> {
if !disk_root.is_dir() {
return Ok(Vec::new());
}
let mut snippets = Vec::new();
let mut paths: BTreeSet<PathBuf> = BTreeSet::new();
collect_snippet_paths(disk_root, disk_root, &mut paths);
for rel in &paths {
if rel.extension() != Some("md".as_ref()) {
continue;
}
if rel.components().count() < 2 {
continue;
}
let md_path = disk_root.join(rel);
let body = fs::read_to_string(&md_path)
.with_context(|| format!("Failed to read {}", md_path.display()))?;
let slot =
path_to_slot(rel).with_context(|| format!("disk snippet {:?}", rel.display()))?;
if sealed.0.contains(&slot) {
continue;
}
let mut selector = default_selector(&slot);
let toml_rel = rel.with_extension("toml");
let toml_path = disk_root.join(&toml_rel);
if toml_path.is_file() {
let toml_text = fs::read_to_string(&toml_path)
.with_context(|| format!("Failed to read {}", toml_path.display()))?;
let sc: Sidecar = toml::from_str(&toml_text)
.with_context(|| format!("invalid sidecar: {}", toml_path.display()))?;
selector = overlay_selector(&selector, &sc)
.with_context(|| format!("disk sidecar {:?}", toml_path.display()))?;
}
snippets.push(crate::hymns::Snippet {
slot,
selector,
provenance: crate::hymns::Provenance::User,
body,
});
}
Ok(snippets)
}
pub(crate) fn load_full_corpus(
disk_root: &Path,
embedded: &[(String, Vec<u8>)],
sealed: &crate::hymns::SealSet,
) -> anyhow::Result<Vec<crate::hymns::Snippet>> {
let mut corpus = load_embedded_corpus(embedded)?;
let mut disk = load_disk_corpus(disk_root, sealed)?;
corpus.append(&mut disk);
Ok(corpus)
}
pub(crate) fn resolve_worker_role_body(
corpus: &[crate::hymns::Snippet],
sealed: &crate::hymns::SealSet,
traits: &BTreeSet<String>,
) -> Result<String, crate::hymns::ResolveError> {
crate::hymns::resolve(&crate::hymns::worker_context(traits), corpus, sealed)
}
pub(crate) fn expand_worker_marker(def: &str, body: &str) -> String {
def.replace(WORKER_RESOLVE_MARKER, body)
}
fn bake_worker_def(
def_str: &str,
asset_label: &str,
corpus: &[crate::hymns::Snippet],
sealed: &crate::hymns::SealSet,
) -> anyhow::Result<String> {
let traits = parse_agent_def_traits(def_str)
.with_context(|| format!("agent def '{asset_label}' frontmatter"))?;
let uncovered = crate::hymns::traits_covered(&traits, corpus);
if !uncovered.is_empty() {
bail!(
"dispatch-worker def '{asset_label}' declares uncovered trait(s) {uncovered:?}; \
a contractless worker cannot ship — add a Model-band hymn covering each key",
);
}
let body = resolve_worker_role_body(corpus, sealed, &traits)?;
Ok(expand_worker_marker(def_str, &body))
}
pub(crate) fn project_starters(
disk_root: &Path,
embedded: &[(String, Vec<u8>)],
sealed: &crate::hymns::SealSet,
exposed_slots: &BTreeSet<crate::hymns::Slot>,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let mut embedded_by_slot: std::collections::BTreeMap<crate::hymns::Slot, String> =
std::collections::BTreeMap::new();
for (rel_path, bytes) in embedded {
if !has_ext(rel_path, "md") {
continue;
}
let rel = Path::new(rel_path);
let Ok(slot) = path_to_slot(rel) else {
continue;
};
let Ok(body) = String::from_utf8(bytes.clone()) else {
continue;
};
embedded_by_slot.entry(slot).or_insert(body);
}
let mut written = Vec::new();
for slot in exposed_slots {
if sealed.0.contains(slot) {
continue;
}
let Some(body) = embedded_by_slot.get(slot) else {
continue;
};
let dir = disk_root.join(slot.band.as_str());
let md = dir.join(format!("{}.md", slot.label));
let toml = dir.join(format!("{}.toml", slot.label));
if !md.exists() {
project_write_if_absent(&md, body.as_bytes(), dry_run)?;
written.push(md);
}
if !toml.exists() {
let sidecar = format!("replaces = \"{}\"\n", slot.path());
project_write_if_absent(&toml, sidecar.as_bytes(), dry_run)?;
written.push(toml);
}
}
Ok(written)
}
fn project_write_if_absent(dest: &Path, bytes: &[u8], dry_run: bool) -> anyhow::Result<()> {
if dry_run {
return Ok(());
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
crate::fsutil::write_atomic(dest, bytes).with_context(|| format!("project {}", dest.display()))
}
fn parse_seal_slot(s: &str) -> anyhow::Result<crate::hymns::Slot> {
let (band_seg, label) = s
.split_once('/')
.with_context(|| format!("seal entry {s:?}: expected 'band/label'"))?;
let band = crate::hymns::Band::from_segment(band_seg)
.with_context(|| format!("seal entry {s:?}: unknown band {band_seg:?}"))?;
Ok(crate::hymns::Slot::new(band, label))
}
fn load_manifest() -> anyhow::Result<Manifest> {
let file = Assets::get("manifest.toml")
.context("install/manifest.toml is missing from embedded assets")?;
let text =
std::str::from_utf8(&file.data).context("install/manifest.toml is not valid UTF-8")?;
let manifest: Manifest =
toml::from_str(text).context("Failed to parse install/manifest.toml")?;
Ok(manifest)
}
fn detect_project_root(explicit: Option<PathBuf>, manifest: &Manifest) -> anyhow::Result<PathBuf> {
crate::root::find(explicit, &manifest.root_markers.markers)
}
fn build_plan(manifest: &Manifest, project_root: &Path) -> Plan {
let target_dir = project_root.join(&manifest.target);
let mut steps = Vec::new();
for dir in &manifest.dirs.create {
let p = project_root.join(dir);
steps.push(Step::CreateDir(p));
}
for filename in embedded_filenames() {
let source = filename.clone();
let dest = target_dir.join(&filename);
if let Some(parent) = dest.parent()
&& !parent.exists()
{
steps.push(Step::CreateDir(parent.to_path_buf()));
}
if dest.exists() {
steps.push(Step::Skip { source, dest });
} else {
steps.push(Step::Install { source, dest });
}
}
let gitignore_path = project_root.join(".gitignore");
let existing = read_gitignore_lines(&gitignore_path);
for entry in &manifest.gitignore.entries {
if !existing.contains(entry.as_str()) {
steps.push(Step::Gitignore {
entry: entry.clone(),
dest: gitignore_path.clone(),
});
}
}
Plan {
project_root: project_root.to_path_buf(),
target_dir,
steps,
}
}
fn embedded_filenames() -> Vec<String> {
let mut names: Vec<String> = Assets::iter()
.map(|f| f.to_string())
.filter(|n| n != "manifest.toml")
.collect();
names.sort();
names
}
fn read_gitignore_lines(path: &Path) -> BTreeSet<String> {
let Ok(content) = fs::read_to_string(path) else {
return BTreeSet::new();
};
content.lines().map(str::to_string).collect()
}
pub(crate) fn ensure_gitignored(root: &Path, entry: &str) -> anyhow::Result<()> {
let path = root.join(".gitignore");
if read_gitignore_lines(&path).contains(entry) {
return Ok(());
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("Failed to open {} for appending", path.display()))?;
writeln!(file, "{entry}")
.with_context(|| format!("Failed to append gitignore entry to {}", path.display()))?;
Ok(())
}
fn stdout_line(msg: &str) -> io::Result<()> {
let mut stdout = io::stdout();
writeln!(stdout, "{msg}")
}
fn print_plan(plan: &Plan) -> io::Result<()> {
let mut stdout = io::stdout();
writeln!(stdout, "Project root: {}", plan.project_root.display())?;
writeln!(stdout, "Target: {}", plan.target_dir.display())?;
writeln!(stdout)?;
for step in &plan.steps {
match step {
Step::CreateDir(path) => {
let flag = if path.exists() { " (exists)" } else { "" };
writeln!(stdout, " create dir {}{}", path.display(), flag)?;
}
Step::Install { source, dest } => {
writeln!(stdout, " install {} → {}", source, dest.display())?;
}
Step::Skip { source, dest } => {
writeln!(
stdout,
" skip {} → {} (exists)",
source,
dest.display()
)?;
}
Step::Gitignore { entry, dest } => {
writeln!(stdout, " gitignore + \"{entry}\" ({})", dest.display())?;
}
}
}
Ok(())
}
pub(crate) fn prompt_confirm(prompt: &str) -> anyhow::Result<bool> {
let mut stdout = io::stdout();
write!(stdout, "{prompt}")?;
stdout.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
Ok(trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes"))
}
fn detect_agents(agents: &[String], root: &Path) -> Vec<String> {
if !agents.is_empty() {
return agents.to_vec();
}
let mut detected: Vec<String> = Vec::new();
if root.join(".claude").exists() {
detected.push("claude".to_string());
}
if root.join(".codex").exists() {
detected.push("codex".to_string());
}
if root.join(".pi").exists() {
detected.push("pi".to_string());
}
if root.join(".agents").exists() {
detected.push("universal".to_string());
}
detected
}
fn prompt_step(question: &str, yes: bool, all_yes: &mut bool) -> io::Result<bool> {
if yes || *all_yes {
return Ok(true);
}
let mut stdout = io::stdout();
write!(stdout, "\n{question} ")?;
stdout.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
match line.trim().to_lowercase().as_str() {
"y" => Ok(true),
"a" => {
*all_yes = true;
Ok(true)
}
_ => Ok(false),
}
}
fn execute_plan(plan: &Plan) -> anyhow::Result<()> {
for step in &plan.steps {
match step {
Step::CreateDir(path) => {
fs::create_dir_all(path)
.with_context(|| format!("Failed to create directory {}", path.display()))?;
}
Step::Install { source, dest } => {
let file = Assets::get(source)
.with_context(|| format!("Embedded file '{source}' not found"))?;
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent dir for {}", dest.display())
})?;
}
#[expect(clippy::disallowed_methods, reason = "derived asset unpack")]
fs::write(dest, &file.data)
.with_context(|| format!("Failed to write {}", dest.display()))?;
}
Step::Skip { .. } => {
}
Step::Gitignore { entry, .. } => {
ensure_gitignored(&plan.project_root, entry)?;
}
}
}
Ok(())
}
fn program_available(prog: &str) -> bool {
std::process::Command::new(prog)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub(crate) fn resolve_runner() -> (&'static str, ProcessRunner) {
resolve_runner_with(&program_available)
}
fn resolve_runner_with(check: &dyn Fn(&str) -> bool) -> (&'static str, ProcessRunner) {
if check(RUNNER_NPX) {
(RUNNER_NPX, ProcessRunner { name: RUNNER_NPX })
} else {
(RUNNER_BUNX, ProcessRunner { name: RUNNER_BUNX })
}
}
#[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>,
}
fn frontmatter_yaml(md: &str) -> anyhow::Result<&str> {
let after = md
.strip_prefix("---")
.context("missing leading '---' frontmatter")?
.trim_start_matches(['\r', '\n']);
let end = after
.find("\n---")
.context("frontmatter is not terminated by '---'")?;
after.get(..end).context("frontmatter slice out of range")
}
fn parse_meta(md: &str) -> anyhow::Result<Meta> {
let yaml = frontmatter_yaml(md).context("SKILL.md")?;
let meta: Meta = serde_yaml::from_str(yaml).context("Failed to parse SKILL.md frontmatter")?;
Ok(meta)
}
#[derive(Debug, Default, Deserialize)]
struct AgentDefMeta {
traits: Option<Vec<String>>,
}
pub(crate) fn parse_agent_def_traits(def: &str) -> anyhow::Result<BTreeSet<String>> {
let yaml = frontmatter_yaml(def).context("agent def")?;
let meta: AgentDefMeta =
serde_yaml::from_str(yaml).context("Failed to parse agent def frontmatter")?;
Ok(meta.traits.unwrap_or_default().into_iter().collect())
}
pub(crate) 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)
}
pub(crate) 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()
}
pub(crate) 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 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_agents_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".claude/agents"))
}
fn pi_agents_dir(root: &Path, global: bool) -> anyhow::Result<PathBuf> {
Ok(install_base(root, global)?.join(".pi/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 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(
agents: &[&str],
skills: &[&Entry],
global: bool,
subset: bool,
repo: &str,
) -> Vec<String> {
let mut argv = vec!["skills".to_string(), "add".to_string(), repo.to_string()];
for agent in agents {
argv.push("--agent".to_string());
argv.push(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
}
pub(crate) trait Runner: std::fmt::Debug {
fn run(&self, program: &str, args: &[String]) -> anyhow::Result<bool>;
}
#[derive(Debug)]
pub(crate) struct ProcessRunner {
name: &'static str,
}
impl Runner for ProcessRunner {
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 {} installed?)", self.name))?;
Ok(status.success())
}
}
pub(crate) struct InstallOtherArgs<'a> {
pub(crate) agent_names: &'a [String],
pub(crate) selected: &'a [&'a Entry],
pub(crate) global: bool,
pub(crate) repo: &'a str,
pub(crate) runner: &'a dyn Runner,
pub(crate) runner_name: &'a str,
}
pub(crate) fn install_for_other(
args: &InstallOtherArgs<'_>,
out: &mut dyn Write,
) -> anyhow::Result<()> {
let subset = !args.selected.is_empty();
let agent_strs: Vec<&str> = args.agent_names.iter().map(String::as_str).collect();
let argv = delegate_argv(&agent_strs, args.selected, args.global, subset, args.repo);
let label = args.agent_names.join(", ");
writeln!(
out,
"agents {label} (delegate): {} {}",
args.runner_name,
argv.join(" ")
)?;
if !args.runner.run(args.runner_name, &argv)? {
bail!(
"{runner_name} skills failed for agents: {label}",
runner_name = args.runner_name,
label = label
);
}
Ok(())
}
pub(crate) fn select_for_install<'a>(
catalog: &'a [Entry],
skills: &[String],
domains: &[String],
) -> anyhow::Result<Vec<&'a Entry>> {
validate_filters(catalog, skills, domains)?;
Ok(select(catalog, skills, domains))
}
pub(crate) fn install_agents_for(
root: &Path,
agent_name: &str,
canon_subdir: Option<&str>,
global: bool,
dry_run: bool,
out: &mut dyn Write,
) -> anyhow::Result<()> {
let embed_asset = match agent_name {
"claude" => DISPATCH_WORKER_AGENT_ASSET,
_ => DISPATCH_WORKER_AGENT_ASSET_PI,
};
install_agent_def(
root,
agent_name,
canon_subdir,
embed_asset,
global,
dry_run,
out,
)
}
pub(crate) fn install_agent_def(
root: &Path,
agent_name: &str,
canon_subdir: Option<&str>,
embed_asset: &str,
global: bool,
dry_run: bool,
out: &mut dyn Write,
) -> anyhow::Result<()> {
let canon_base = agent_canonical_dir(root, global)?;
let canon_dir = match canon_subdir {
Some(sub) => canon_base.join(sub),
None => canon_base,
};
let link_dir = match agent_name {
"claude" => claude_agents_dir(root, global)?,
_ => pi_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 {agent_name} (dispatch-worker):")?;
writeln!(
out,
" agent {DISPATCH_WORKER_AGENT_FILE} → {}",
dest.display()
)?;
if dry_run {
return Ok(());
}
let data = embedded_asset(embed_asset)
.with_context(|| format!("Embedded agent def '{embed_asset}' not found"))?;
fs::create_dir_all(&canon_dir)
.with_context(|| format!("Failed to create {}", canon_dir.display()))?;
if let Ok(def_str) = std::str::from_utf8(&data)
&& def_str.contains(WORKER_RESOLVE_MARKER)
{
let disk = root.join(".doctrine").join(HYMNS_DIRNAME);
let embedded = embedded_hymns();
let sealed = embedded_seal_set()?;
let corpus = load_full_corpus(&disk, &embedded, &sealed)?;
let expanded = bake_worker_def(def_str, embed_asset, &corpus, &sealed)?;
crate::fsutil::write_atomic(&canon, expanded.as_bytes())?;
} else {
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(())
}
#[cfg(test)]
mod tests_hymns {
use super::*;
#[test]
fn parse_seal_slot_valid() {
let slot = parse_seal_slot("harness/claude").unwrap();
assert_eq!(slot.band, crate::hymns::Band::Harness);
assert_eq!(slot.label, "claude");
}
#[test]
fn parse_seal_slot_model_with_slash_in_label() {
let slot = parse_seal_slot("model/anthropic/claude-sonnet-4").unwrap();
assert_eq!(slot.band, crate::hymns::Band::Model);
assert_eq!(slot.label, "anthropic/claude-sonnet-4");
}
#[test]
fn parse_seal_slot_rejects_unknown_band() {
let err = parse_seal_slot("nope/something").unwrap_err();
assert!(err.to_string().contains("unknown band"), "{err}");
}
#[test]
fn parse_seal_slot_rejects_missing_slash() {
let err = parse_seal_slot("noslash").unwrap_err();
assert!(err.to_string().contains("band/label"), "{err}");
}
#[test]
fn embedded_seal_set_from_live_manifest() {
let sealed = embedded_seal_set().unwrap();
assert_eq!(sealed.0.len(), 1);
let slot = sealed.0.first().unwrap();
assert_eq!(slot.band, crate::hymns::Band::Preamble);
assert_eq!(slot.label, "core");
}
#[test]
fn embedded_hymns_from_live_dir() {
let hymns = embedded_hymns();
assert!(
hymns.iter().any(|(name, _)| name == "preamble/core.md"),
"expected preamble/core.md, got: {hymns:?}"
);
assert!(
hymns.iter().any(|(name, _)| name == "harness/claude.md"),
"expected harness/claude.md, got: {hymns:?}"
);
}
#[test]
fn overlay_selector_model_presence_semantics() {
let base = default_selector(&crate::hymns::Slot::new(
crate::hymns::Band::Model,
"anthropic/claude-sonnet-4",
));
let pin: std::collections::BTreeSet<String> = ["anthropic/claude-sonnet-4".into()].into();
assert_eq!(base.model, pin, "default_selector seeds the path pin");
let sc: Sidecar = toml::from_str("").unwrap();
assert_eq!(sc.model, None);
let kept = overlay_selector(&base, &sc).unwrap();
assert_eq!(kept.model, pin, "omitted model must keep the path pin");
let sc: Sidecar =
toml::from_str("model = [\"capability/code/high\", \"capability/reasoning/high\"]")
.unwrap();
let replaced = overlay_selector(&base, &sc).unwrap();
let want: std::collections::BTreeSet<String> = [
"capability/code/high".into(),
"capability/reasoning/high".into(),
]
.into();
assert_eq!(replaced.model, want, "declared list must replace the pin");
let sc: Sidecar = toml::from_str("model = []").unwrap();
assert_eq!(sc.model, Some(vec![]));
let unpinned = overlay_selector(&base, &sc).unwrap();
assert!(unpinned.model.is_empty(), "empty list must unpin the axis");
}
fn worker_slot() -> crate::hymns::Slot {
crate::hymns::Slot::new(crate::hymns::Band::Role, "worker")
}
const FRAMEWORK_BODY: &str = "FRAMEWORK WORKER BODY";
fn worker_embedded() -> Vec<(String, Vec<u8>)> {
vec![(
"role/worker.md".to_string(),
FRAMEWORK_BODY.as_bytes().to_vec(),
)]
}
fn exposed_set(slot: crate::hymns::Slot) -> BTreeSet<crate::hymns::Slot> {
BTreeSet::from([slot])
}
fn no_seal() -> crate::hymns::SealSet {
crate::hymns::SealSet(BTreeSet::new())
}
#[test]
fn project_starters_writes_self_replaces_sidecar() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
project_starters(
root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
false,
)
.unwrap();
let sidecar = fs::read_to_string(root.join("role").join("worker.toml")).unwrap();
assert!(
sidecar.contains("replaces = \"role/worker\""),
"sidecar must carry the self-replaces, got: {sidecar:?}"
);
}
#[test]
fn project_starters_backfills_sidecar_preserving_md() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let md = root.join("role").join("worker.md");
fs::create_dir_all(md.parent().unwrap()).unwrap();
fs::write(&md, "PRE-EXISTING ORPHAN BODY").unwrap();
project_starters(
root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
false,
)
.unwrap();
assert_eq!(
fs::read_to_string(&md).unwrap(),
"PRE-EXISTING ORPHAN BODY",
"existing .md must be preserved byte-for-byte"
);
let sidecar = fs::read_to_string(root.join("role").join("worker.toml")).unwrap();
assert!(sidecar.contains("replaces = \"role/worker\""));
}
#[test]
fn project_starters_idempotent_when_both_present() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let md = root.join("role").join("worker.md");
let toml = root.join("role").join("worker.toml");
fs::create_dir_all(md.parent().unwrap()).unwrap();
fs::write(&md, "EXISTING MD").unwrap();
fs::write(&toml, "replaces = \"role/worker\"\n# hand-tuned axis").unwrap();
let written = project_starters(
root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
false,
)
.unwrap();
assert!(written.is_empty(), "both present ⇒ nothing written");
assert_eq!(fs::read_to_string(&md).unwrap(), "EXISTING MD");
assert_eq!(
fs::read_to_string(&toml).unwrap(),
"replaces = \"role/worker\"\n# hand-tuned axis",
"present sidecar must be left untouched (no clobber of hand-tuned axes)"
);
}
#[test]
fn project_starters_preserves_edited_md() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let md = root.join("role").join("worker.md");
fs::create_dir_all(md.parent().unwrap()).unwrap();
let edited = "USER EDITED B-PRIME BODY (distinct from framework)";
fs::write(&md, edited).unwrap();
project_starters(
root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
false,
)
.unwrap();
let after = fs::read_to_string(&md).unwrap();
assert_eq!(
after, edited,
"user customisation must survive re-projection"
);
assert_ne!(
after, FRAMEWORK_BODY,
"must not be overwritten with framework body"
);
}
#[test]
fn project_starters_skips_sealed_slot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let seal = crate::hymns::SealSet(BTreeSet::from([worker_slot()]));
let written = project_starters(
root,
&worker_embedded(),
&seal,
&exposed_set(worker_slot()),
false,
)
.unwrap();
assert!(written.is_empty(), "sealed slot ⇒ nothing written");
assert!(!root.join("role").join("worker.md").exists());
assert!(!root.join("role").join("worker.toml").exists());
}
#[test]
fn project_starters_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
project_starters(
root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
true,
)
.unwrap();
assert!(!root.join("role").join("worker.md").exists());
assert!(!root.join("role").join("worker.toml").exists());
}
#[test]
fn project_starters_creates_missing_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("empty-hymns-root");
project_starters(
&root,
&worker_embedded(),
&no_seal(),
&exposed_set(worker_slot()),
false,
)
.unwrap();
assert!(
root.join("role").join("worker.md").exists(),
"missing parent dir must be created before write_atomic"
);
assert!(root.join("role").join("worker.toml").exists());
}
#[test]
fn worker_resolve_composes_framework_role_worker_and_nonreplacing_project_habit() {
const HABIT_SENTINEL: &str = "DOCTRINE-RUST-HABIT-SENTINEL-VT1";
let dir = tempfile::tempdir().unwrap();
let disk_root = dir.path();
let habit = disk_root.join("project").join("doctrine-conventions.md");
fs::create_dir_all(habit.parent().unwrap()).unwrap();
fs::write(&habit, HABIT_SENTINEL).unwrap();
let embedded = embedded_hymns();
let sealed = embedded_seal_set().unwrap();
let corpus = load_full_corpus(disk_root, &embedded, &sealed).unwrap();
let ctx = crate::hymns::ContextVector {
role: crate::hymns::Role::Worker,
harness: None,
model: BTreeSet::new(),
arm: None,
stage: None,
bands: crate::hymns::BandFilter::All,
};
let body = crate::hymns::resolve(&ctx, &corpus, &sealed).unwrap();
assert_eq!(
body.matches("NEGATIVE CONTRACT").count(),
1,
"enriched Framework role/worker contract must compose exactly once \
(not suppressed, not doubled):\n{body}"
);
assert_eq!(
body.matches(HABIT_SENTINEL).count(),
1,
"non-replacing project-band client habit must compose exactly once:\n{body}"
);
}
const FORBIDDEN_HOST_LITERALS: &[&str] = &["cargo", "target/", "just", "node_modules"];
#[test]
fn install_hymns_authored_set_has_no_host_literals() {
let authored: Vec<(String, Vec<u8>)> = embedded_hymns()
.into_iter()
.filter(|(rel, _)| rel == "role/worker.md" || rel.starts_with("model/"))
.collect();
assert!(
!authored.is_empty(),
"expected the authored hymn set (role/worker.md + model/**) to be non-empty"
);
for (rel, bytes) in &authored {
let text = String::from_utf8_lossy(bytes).to_lowercase();
for literal in FORBIDDEN_HOST_LITERALS {
assert!(
!text.contains(literal),
"authored hymn '{rel}' contains forbidden host literal '{literal}' — \
POL-002 requires host-agnostic shipped corpus"
);
}
}
}
}
#[cfg(test)]
mod tests_skills {
use super::*;
const TEST_REPO: &str = "davidlee/doctrine";
#[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 parse_agent_def_traits_reads_declared_keys() {
let def = "---\nname: dispatch-worker\ntraits: [\"adherence/low\"]\n---\n\nbody\n";
let traits = parse_agent_def_traits(def).unwrap();
assert_eq!(traits, ["adherence/low".to_string()].into());
}
#[test]
fn parse_agent_def_traits_absent_traits_is_empty_ok() {
let def = "---\nname: dispatch-worker\nmodel: some/model\n---\n\nbody\n";
let traits = parse_agent_def_traits(def).unwrap();
assert!(traits.is_empty());
}
#[test]
fn parse_agent_def_traits_tolerates_unknown_keys() {
let def = "---\nname: dispatch-worker\ndescription: d\ntools: read, edit\nmodel: deepseek/deepseek-v4-pro\ntraits: [\"adherence/low\"]\n---\n\nbody\n";
let traits = parse_agent_def_traits(def).unwrap();
assert_eq!(traits, ["adherence/low".to_string()].into());
}
#[test]
fn parse_agent_def_traits_rejects_unterminated_frontmatter() {
assert!(parse_agent_def_traits("---\nname: x\nno closing fence\n").is_err());
}
#[test]
fn parse_agent_def_traits_rejects_missing_frontmatter() {
assert!(parse_agent_def_traits("# 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 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, TEST_REPO);
assert_eq!(
argv,
vec!["skills", "add", TEST_REPO, "--agent", "codex", "--yes"]
);
}
#[test]
fn delegate_argv_subset_and_global() {
let e = entry("review", "code-review");
let argv = delegate_argv(&["cursor"], &[&e], true, true, TEST_REPO);
assert_eq!(
argv,
vec![
"skills",
"add",
TEST_REPO,
"--agent",
"cursor",
"--global",
"--skill",
"code-review",
"--yes",
]
);
}
#[test]
fn delegate_argv_multiple_agents() {
let e = entry("review", "code-review");
let argv = delegate_argv(&["pi", "codex"], &[&e], false, false, TEST_REPO);
assert_eq!(
argv,
vec![
"skills", "add", TEST_REPO, "--agent", "pi", "--agent", "codex", "--yes",
]
);
}
#[test]
fn resolve_runner_with_npx_available() {
let (name, _runner) = resolve_runner_with(&|prog| prog == "npx");
assert_eq!(name, RUNNER_NPX);
}
#[test]
fn resolve_runner_with_falls_back_to_bunx() {
let (name, _runner) = resolve_runner_with(&|_prog| false);
assert_eq!(name, RUNNER_BUNX);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_marketplace_manifest(root: &Path) {
let dir = root.join(".claude-plugin");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("marketplace.json"),
r#"{"name":"doctrine","plugins":[{"name":"doctrine-memory"},{"name":"doctrine"}]}"#,
)
.unwrap();
}
#[test]
fn enable_key_is_qualified_doctrine() {
assert_eq!(enable_key(), "doctrine@doctrine");
}
#[test]
fn select_plugin_picks_by_name_not_first() {
let manifest = MarketplaceManifest {
name: "doctrine".into(),
plugins: vec![
ManifestPlugin {
name: "doctrine-memory".into(),
},
ManifestPlugin {
name: "doctrine-partner".into(),
},
ManifestPlugin {
name: "doctrine".into(),
},
],
};
assert_eq!(select_plugin(&manifest), Some("doctrine"));
assert_ne!(manifest.plugins[0].name, "doctrine", "[0] would be wrong");
}
#[test]
fn plugin_presence_is_exact_not_substring() {
let fixture =
"Installed plugins:\n\n ❯ doctrine-partner@doctrine\n Status: ✔ enabled\n";
assert!(
!claude_list_has(fixture, "doctrine@doctrine"),
"sibling doctrine-partner@doctrine must not false-satisfy"
);
assert!(fixture.contains("doctrine"));
let present = " ❯ doctrine@doctrine\n Status: ✔ enabled\n";
assert!(claude_list_has(present, "doctrine@doctrine"));
}
#[test]
fn marketplace_presence_is_exact_token() {
let present = "Configured marketplaces:\n\n ❯ doctrine\n Source: Directory (/workspace/doctrine)\n";
assert!(claude_list_has(present, "doctrine"));
let other = " ❯ other\n Source: GitHub (davidlee/doctrine)\n";
assert!(!claude_list_has(other, "doctrine"));
}
#[test]
fn source_default_is_github_slug() {
let cwd = tempfile::tempdir().unwrap();
let src =
select_marketplace_source(Path::new("/unused"), cwd.path(), "davidlee/doctrine", false)
.unwrap();
assert_eq!(src, MarketplaceSource::Github("davidlee/doctrine".into()));
}
#[test]
fn source_dev_is_directory_abs() {
let dir = tempfile::tempdir().unwrap();
write_marketplace_manifest(dir.path());
let src =
select_marketplace_source(dir.path(), Path::new("/unused"), "davidlee/doctrine", true)
.unwrap();
match src {
MarketplaceSource::Directory(p) => {
assert!(p.is_absolute());
assert_eq!(p, fs::canonicalize(dir.path()).unwrap());
}
other => panic!("expected Directory, got {other:?}"),
}
}
#[test]
fn source_dev_missing_manifest_errors() {
let dir = tempfile::tempdir().unwrap();
let err =
select_marketplace_source(dir.path(), Path::new("/unused"), "davidlee/doctrine", true)
.unwrap_err();
assert!(
err.to_string().contains("marketplace manifest"),
"expected a manifest-absent error, got: {err}"
);
}
#[test]
fn source_dev_relative_root_canonicalizes_absolute() {
let base = tempfile::tempdir().unwrap();
let proj = base.path().join("proj");
fs::create_dir_all(&proj).unwrap();
write_marketplace_manifest(&proj);
let src =
select_marketplace_source(Path::new("proj"), base.path(), "davidlee/doctrine", true)
.unwrap();
match src {
MarketplaceSource::Directory(p) => {
assert!(p.is_absolute(), "relative root must yield absolute source");
assert_eq!(p, fs::canonicalize(&proj).unwrap());
}
other => panic!("expected Directory, got {other:?}"),
}
}
#[test]
fn parse_registered_source_reads_directory_and_github() {
let dir = "Configured marketplaces:\n\n ❯ other\n Source: GitHub (a/b)\n\n ❯ doctrine\n Source: Directory (/workspace/doctrine)\n";
assert_eq!(
parse_registered_source(dir, "doctrine"),
Some(RegisteredSource::Directory("/workspace/doctrine".into()))
);
let gh = " ❯ doctrine\n Source: GitHub (davidlee/doctrine)\n";
assert_eq!(
parse_registered_source(gh, "doctrine"),
Some(RegisteredSource::Github("davidlee/doctrine".into()))
);
}
#[test]
fn parse_registered_source_absent_or_sibling_is_none() {
let none = "Configured marketplaces:\n\n ❯ caveman\n Source: GitHub (j/c)\n";
assert_eq!(parse_registered_source(none, "doctrine"), None);
let sibling = " ❯ doctrine-memory\n Source: Directory (/tmp/x)\n";
assert_eq!(parse_registered_source(sibling, "doctrine"), None);
}
#[test]
fn marketplace_action_add_skip_refresh() {
let intended = MarketplaceSource::Directory(PathBuf::from("/workspace/doctrine"));
assert_eq!(marketplace_action(None, &intended), MarketplaceAction::Add);
assert_eq!(
marketplace_action(
Some(RegisteredSource::Directory("/workspace/doctrine".into())),
&intended
),
MarketplaceAction::Skip
);
assert_eq!(
marketplace_action(
Some(RegisteredSource::Directory("/old/path".into())),
&intended
),
MarketplaceAction::Refresh
);
let gh = MarketplaceSource::Github("davidlee/doctrine".into());
assert_eq!(
marketplace_action(
Some(RegisteredSource::Github("davidlee/doctrine".into())),
&gh
),
MarketplaceAction::Skip
);
assert_eq!(
marketplace_action(
Some(RegisteredSource::Directory("/workspace/doctrine".into())),
&gh
),
MarketplaceAction::Refresh
);
}
#[test]
fn refresh_failure_is_fatal_only_on_refresh() {
assert!(refresh_failure_is_fatal(&MarketplaceAction::Refresh));
assert!(!refresh_failure_is_fatal(&MarketplaceAction::Add));
assert!(!refresh_failure_is_fatal(&MarketplaceAction::Skip));
}
#[test]
fn detects_root_via_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest::default_for_tests();
let result = detect_project_root(Some(dir.path().to_path_buf()), &manifest).unwrap();
assert_eq!(result, dir.path());
}
#[test]
fn detect_root_explicit_overrides_walking() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest::default_for_tests();
let result = detect_project_root(Some(dir.path().to_path_buf()), &manifest).unwrap();
assert_eq!(result, dir.path());
}
#[test]
fn detect_root_custom_markers_uses_explicit() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join(".myproject");
fs::write(&marker, "").unwrap();
let sub = dir.path().join("deep/nested");
fs::create_dir_all(&sub).unwrap();
let manifest = Manifest {
root_markers: RootMarkersSection {
markers: vec![".myproject".to_string()],
},
..Manifest::default_for_tests()
};
let result = detect_project_root(Some(sub), &manifest).unwrap();
assert_eq!(result, dir.path().join("deep/nested"));
}
#[test]
fn glossary_is_shipped() {
let names = embedded_filenames();
assert!(
names.contains(&"glossary.md".to_string()),
"glossary.md must be embedded (shipped); got {names:?}"
);
assert!(
!asset_text("glossary.md").unwrap().trim().is_empty(),
"glossary.md asset must be non-empty"
);
}
#[test]
fn using_doctrine_is_shipped() {
let names = embedded_filenames();
assert!(
names.contains(&"using-doctrine.md".to_string()),
"using-doctrine.md must be embedded (shipped); got {names:?}"
);
assert!(
!asset_text("using-doctrine.md").unwrap().trim().is_empty(),
"using-doctrine.md asset must be non-empty"
);
}
#[test]
fn review_ledger_is_shipped() {
let names = embedded_filenames();
assert!(
names.contains(&"review-ledger.md".to_string()),
"review-ledger.md must be embedded (shipped); got {names:?}"
);
assert!(
!asset_text("review-ledger.md").unwrap().trim().is_empty(),
"review-ledger.md asset must be non-empty"
);
}
#[test]
fn plan_creates_dirs_from_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
dirs: DirsSection {
create: vec!["foo/bar".to_string(), "baz".to_string()],
},
..Manifest::default_for_tests()
};
let plan = build_plan(&manifest, dir.path());
let dirs: Vec<_> = plan
.steps
.iter()
.filter_map(|s| match s {
Step::CreateDir(p) => Some(p.clone()),
_ => None,
})
.collect();
assert!(dirs.contains(&dir.path().join("foo/bar")));
assert!(dirs.contains(&dir.path().join("baz")));
}
#[test]
fn plan_skips_existing_files() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join(".doctrine");
fs::create_dir_all(&target).unwrap();
let existing = target.join("glossary.md");
fs::write(&existing, "old content").unwrap();
let manifest = Manifest::default_for_tests();
let plan = build_plan(&manifest, dir.path());
let has_skip = plan
.steps
.iter()
.any(|s| matches!(s, Step::Skip { dest, .. } if dest == &existing));
assert!(has_skip, "Expected a Skip step for the pre-existing file");
}
#[test]
fn plan_includes_gitignore_entries() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
gitignore: GitignoreSection {
entries: vec!["ignored-dir/".to_string()],
},
..Manifest::default_for_tests()
};
let plan = build_plan(&manifest, dir.path());
let gi: Vec<_> = plan
.steps
.iter()
.filter_map(|s| match s {
Step::Gitignore { entry, .. } => Some(entry.clone()),
_ => None,
})
.collect();
assert_eq!(gi, vec!["ignored-dir/".to_string()]);
}
#[test]
fn gitignore_skips_duplicate_entries() {
let dir = tempfile::tempdir().unwrap();
let gi = dir.path().join(".gitignore");
fs::write(&gi, "skip-me\n").unwrap();
let manifest = Manifest {
gitignore: GitignoreSection {
entries: vec!["skip-me".to_string(), "new-one".to_string()],
},
..Manifest::default_for_tests()
};
let plan = build_plan(&manifest, dir.path());
let entries: Vec<_> = plan
.steps
.iter()
.filter_map(|s| match s {
Step::Gitignore { entry, .. } => Some(entry.as_str()),
_ => None,
})
.collect();
assert_eq!(entries, vec!["new-one"]);
}
#[test]
fn embedded_manifest_gitignores_the_runtime_state_surface() {
let manifest = load_manifest().unwrap();
for entry in [
".doctrine/state/",
".doctrine/slice/*/phases",
".doctrine/**/handover.md",
] {
assert!(
manifest.gitignore.entries.iter().any(|e| e == entry),
"manifest must gitignore {entry}"
);
}
}
#[test]
fn embedded_manifest_creates_memory_items_and_ignores_derived_subtrees() {
let manifest = load_manifest().unwrap();
assert!(
manifest
.dirs
.create
.iter()
.any(|d| d == ".doctrine/memory/items"),
"manifest must create the memory items tree"
);
for derived in [
".doctrine/memory/index/*",
".doctrine/memory/embeddings/*",
".doctrine/memory/state/*",
".doctrine/memory/shipped/",
] {
assert!(
manifest.gitignore.entries.iter().any(|e| e == derived),
"manifest must gitignore {derived}"
);
assert!(
!manifest.dirs.create.iter().any(|d| d == derived),
"manifest must not create the derived subtree {derived}"
);
}
assert!(
!manifest
.gitignore
.entries
.iter()
.any(|e| e == ".doctrine/memory/*" || e == ".doctrine/memory/"),
"manifest must not blanket-ignore the memory tree"
);
}
#[test]
fn embedded_manifest_creates_the_policy_tree() {
let manifest = load_manifest().unwrap();
assert!(
manifest.dirs.create.iter().any(|d| d == ".doctrine/policy"),
"manifest must create the authored policy tree"
);
assert!(
!manifest
.gitignore
.entries
.iter()
.any(|e| e.starts_with(".doctrine/policy")),
"the authored policy tree must not be gitignored by the manifest"
);
}
#[test]
fn embedded_manifest_creates_the_standard_tree() {
let manifest = load_manifest().unwrap();
assert!(
manifest
.dirs
.create
.iter()
.any(|d| d == ".doctrine/standard"),
"manifest must create the authored standard tree"
);
assert!(
!manifest
.gitignore
.entries
.iter()
.any(|e| e.starts_with(".doctrine/standard")),
"the authored standard tree must not be gitignored by the manifest"
);
}
#[test]
fn embedded_manifest_creates_the_review_tree_and_embeds_its_templates() {
let manifest = load_manifest().unwrap();
assert!(
manifest.dirs.create.iter().any(|d| d == ".doctrine/review"),
"manifest must create the authored review tree"
);
assert!(
!manifest
.gitignore
.entries
.iter()
.any(|e| e.starts_with(".doctrine/review")),
"the authored review tree must not be gitignored by the manifest"
);
for tpl in ["templates/review.toml", "templates/review.md"] {
assert!(
!asset_text(tpl).unwrap().trim().is_empty(),
"{tpl} must be embedded and non-empty"
);
}
}
#[test]
fn embedded_manifest_creates_the_rec_tree_and_embeds_its_templates() {
let manifest = load_manifest().unwrap();
assert!(
manifest.dirs.create.iter().any(|d| d == ".doctrine/rec"),
"manifest must create the authored rec tree"
);
assert!(
!manifest
.gitignore
.entries
.iter()
.any(|e| e.starts_with(".doctrine/rec")),
"the authored rec tree must not be gitignored by the manifest"
);
for tpl in ["templates/rec.toml", "templates/rec.md"] {
assert!(
!asset_text(tpl).unwrap().trim().is_empty(),
"{tpl} must be embedded and non-empty"
);
}
}
#[test]
fn embedded_manifest_ignores_the_skills_derived_tree() {
let manifest = load_manifest().unwrap();
assert!(
!manifest.dirs.create.iter().any(|d| d == ".doctrine/skills"),
"skills dir is created by `skills install`, not `doctrine install`"
);
assert!(
manifest
.gitignore
.entries
.iter()
.any(|e| e == ".doctrine/skills/*"),
"manifest must gitignore the derived skills tree"
);
}
#[test]
fn ensure_gitignored_appends_once_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let gi = dir.path().join(".gitignore");
ensure_gitignored(dir.path(), ".doctrine/skills/*").unwrap();
assert!(gi.is_file());
let after_first = fs::read_to_string(&gi).unwrap();
assert!(after_first.contains(".doctrine/skills/*"));
ensure_gitignored(dir.path(), ".doctrine/skills/*").unwrap();
let after_second = fs::read_to_string(&gi).unwrap();
assert_eq!(after_first, after_second);
assert_eq!(
after_second.matches(".doctrine/skills/*").count(),
1,
"entry must appear exactly once"
);
}
#[test]
fn ensure_gitignored_preserves_existing_entries() {
let dir = tempfile::tempdir().unwrap();
let gi = dir.path().join(".gitignore");
fs::write(&gi, "/pre-existing\n").unwrap();
ensure_gitignored(dir.path(), ".doctrine/skills/*").unwrap();
let content = fs::read_to_string(&gi).unwrap();
assert!(content.contains("/pre-existing"));
assert!(content.contains(".doctrine/skills/*"));
}
#[test]
fn execute_creates_dirs_and_files() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
dirs: DirsSection {
create: vec![".doctrine/custom-dir".to_string()],
},
target: ".doctrine".to_string(),
..Manifest::default_for_tests()
};
let plan = build_plan(&manifest, dir.path());
execute_plan(&plan).unwrap();
assert!(dir.path().join(".doctrine/custom-dir").is_dir());
let glossary = dir.path().join(".doctrine/glossary.md");
assert!(glossary.is_file());
let content = fs::read_to_string(&glossary).unwrap();
assert!(content.contains("glossary"));
}
#[test]
fn execute_appends_gitignore() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
gitignore: GitignoreSection {
entries: vec!["/doctest-entry".to_string()],
},
target: ".doctrine".to_string(),
..Manifest::default_for_tests()
};
let plan = build_plan(&manifest, dir.path());
execute_plan(&plan).unwrap();
let gi_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi_content.contains("/doctest-entry"));
}
#[test]
fn install_hints_at_the_standalone_memory_sync_verb() {
assert!(
sync_hint().contains("memory sync"),
"the post-install hint must point at `memory sync`"
);
}
#[test]
fn install_writes_no_shipped_tree() {
let dir = tempfile::tempdir().unwrap();
let manifest = load_manifest().unwrap();
let plan = build_plan(&manifest, dir.path());
execute_plan(&plan).unwrap();
assert!(
dir.path().join(".doctrine/memory/items").is_dir(),
"install materializes the committed items/ tree"
);
assert!(
!dir.path().join(".doctrine/memory/shipped").exists(),
"install must not create the derived shipped/ tree — that is `memory sync`'s job"
);
}
#[test]
fn execute_skips_existing_files() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
target: ".doctrine".to_string(),
..Manifest::default_for_tests()
};
let dest = dir.path().join(".doctrine/glossary.md");
fs::create_dir_all(dest.parent().unwrap()).unwrap();
let original = "original content";
fs::write(&dest, original).unwrap();
let plan = build_plan(&manifest, dir.path());
execute_plan(&plan).unwrap();
let content = fs::read_to_string(&dest).unwrap();
assert_eq!(content, original);
}
#[test]
fn expand_worker_marker_replaces_literal_marker() {
let def = format!("before\n{WORKER_RESOLVE_MARKER}\nafter\n");
let expanded = expand_worker_marker(&def, "resolved");
assert_eq!(expanded, "before\nresolved\nafter\n");
}
#[test]
fn expand_worker_marker_without_marker_is_unchanged() {
let def = "dispatch-worker resolve --role worker".to_string();
let expanded = expand_worker_marker(&def, "ignored");
assert_eq!(expanded, def);
}
#[test]
fn install_agent_def_expands_worker_marker_before_write() {
let dir = tempfile::tempdir().unwrap();
let hymns_dir = dir
.path()
.join(".doctrine")
.join(HYMNS_DIRNAME)
.join("role");
fs::create_dir_all(&hymns_dir).unwrap();
fs::write(hymns_dir.join("worker.md"), "RESOLVED WORKER BODY").unwrap();
let mut out = Vec::new();
install_agent_def(
dir.path(),
"claude",
None,
DISPATCH_WORKER_AGENT_ASSET,
false,
false,
&mut out,
)
.unwrap();
let written =
fs::read_to_string(dir.path().join(".doctrine/agents/dispatch-worker.md")).unwrap();
assert!(written.contains("RESOLVED WORKER BODY"), "{written}");
assert!(!written.contains(WORKER_RESOLVE_MARKER), "{written}");
}
#[test]
fn install_agent_def_without_marker_writes_bytes_identically() {
let dir = tempfile::tempdir().unwrap();
let mut out = Vec::new();
install_agent_def(
dir.path(),
"claude",
None,
"glossary.md",
false,
false,
&mut out,
)
.unwrap();
let expected = embedded_asset("glossary.md").unwrap();
let written = fs::read(dir.path().join(".doctrine/agents/dispatch-worker.md")).unwrap();
assert_eq!(written, expected.as_ref());
}
fn role_plus_adherence_corpus() -> Vec<crate::hymns::Snippet> {
use crate::hymns::{Band, Provenance, Role, Selector, Slot, Snippet};
vec![
Snippet {
slot: Slot::new(Band::Role, "worker"),
selector: Selector {
role: Some(Role::Worker),
..Default::default()
},
provenance: Provenance::Framework,
body: "ROLE WORKER BODY".into(),
},
Snippet {
slot: Slot::new(Band::Model, "adherence-low"),
selector: Selector {
model: ["adherence/low".to_string()].into(),
..Default::default()
},
provenance: Provenance::Framework,
body: "ADHERENCE LOW BODY".into(),
},
]
}
#[test]
fn bake_worker_def_covered_trait_inlines_adherence_via_model_band() {
let corpus = role_plus_adherence_corpus();
let def = format!("---\ntraits: [\"adherence/low\"]\n---\n\n{WORKER_RESOLVE_MARKER}\n");
let out = bake_worker_def(&def, "pi", &corpus, &crate::hymns::SealSet::default()).unwrap();
assert!(out.contains("ADHERENCE LOW BODY"), "{out}");
assert!(!out.contains(WORKER_RESOLVE_MARKER), "{out}");
}
#[test]
fn bake_worker_def_traitless_stays_role_only() {
let corpus = role_plus_adherence_corpus();
let def = format!("---\nname: x\n---\n\n{WORKER_RESOLVE_MARKER}\n");
let out =
bake_worker_def(&def, "claude", &corpus, &crate::hymns::SealSet::default()).unwrap();
assert!(out.contains("ROLE WORKER BODY"), "{out}");
assert!(!out.contains("ADHERENCE LOW BODY"), "{out}");
}
#[test]
fn bake_worker_def_uncovered_trait_is_a_hard_error() {
let corpus = role_plus_adherence_corpus();
let def = format!("---\ntraits: [\"adherance/low\"]\n---\n\n{WORKER_RESOLVE_MARKER}\n");
let err = bake_worker_def(&def, "pi", &corpus, &crate::hymns::SealSet::default())
.expect_err("uncovered trait must fail the bake");
assert!(err.to_string().contains("uncovered trait"), "{err}");
}
#[test]
fn embedded_dispatch_worker_defs_declare_expected_traits() {
let defs = embedded_agent_defs();
let traits_of = |rel: &str| -> BTreeSet<String> {
let (_, bytes) = defs
.iter()
.find(|(name, _)| name == rel)
.unwrap_or_else(|| panic!("embedded agent def {rel} present"));
parse_agent_def_traits(std::str::from_utf8(bytes).unwrap()).unwrap()
};
assert_eq!(
traits_of("pi/dispatch-worker.md"),
["adherence/low".to_string()].into()
);
assert!(traits_of("claude/dispatch-worker.md").is_empty());
}
#[test]
fn seeds_governance_when_missing_and_skips_when_present() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
target: ".doctrine".to_string(),
..Manifest::default_for_tests()
};
let dest = dir.path().join(".doctrine/governance.md");
execute_plan(&build_plan(&manifest, dir.path())).unwrap();
assert!(dest.is_file(), "governance.md seeded when missing");
assert!(
fs::read_to_string(&dest)
.unwrap()
.contains("Project-Specific Governance"),
"seeded from the embedded template",
);
let edited = "# Governance (project)\n\nmy own pointers\n";
fs::write(&dest, edited).unwrap();
execute_plan(&build_plan(&manifest, dir.path())).unwrap();
assert_eq!(
fs::read_to_string(&dest).unwrap(),
edited,
"an existing governance.md is never clobbered",
);
}
#[test]
fn detect_agents_empty_when_no_agent_dirs_and_no_flags() {
let dir = tempfile::tempdir().unwrap();
let agents = detect_agents(&[], dir.path());
assert!(agents.is_empty());
}
#[test]
fn detect_agents_returns_claude_when_dir_present() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".claude")).unwrap();
let agents = detect_agents(&[], dir.path());
assert_eq!(agents, vec!["claude".to_string()]);
}
#[test]
fn detect_agents_returns_pi_when_dir_present() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".pi")).unwrap();
let agents = detect_agents(&[], dir.path());
assert_eq!(agents, vec!["pi".to_string()]);
}
#[test]
fn detect_agents_returns_codex_when_dir_present() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".codex")).unwrap();
let agents = detect_agents(&[], dir.path());
assert_eq!(agents, vec!["codex".to_string()]);
}
#[test]
fn detect_agents_returns_universal_for_dot_agents_dir() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".agents")).unwrap();
let agents = detect_agents(&[], dir.path());
assert_eq!(agents, vec!["universal".to_string()]);
}
#[test]
fn detect_agents_detects_multiple_agent_dirs() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".claude")).unwrap();
fs::create_dir(dir.path().join(".pi")).unwrap();
let agents = detect_agents(&[], dir.path());
assert_eq!(agents, vec!["claude".to_string(), "pi".to_string()]);
}
#[test]
fn detect_agents_uses_explicit_over_detection() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".claude")).unwrap();
let agents = detect_agents(&["pi".to_string()], dir.path());
assert_eq!(agents, vec!["pi".to_string()]);
}
#[test]
fn detect_agents_returns_multiple_explicit() {
let dir = tempfile::tempdir().unwrap();
let agents = detect_agents(&["claude".to_string(), "pi".to_string()], dir.path());
assert_eq!(agents, vec!["claude".to_string(), "pi".to_string()]);
}
#[test]
fn prompt_step_yes_flag_skips_prompt() {
let mut all_yes = false;
assert!(prompt_step("Q?", true, &mut all_yes).unwrap());
assert!(!all_yes); }
#[test]
fn prompt_step_all_yes_already_true_skips_prompt() {
let mut all_yes = true;
assert!(prompt_step("Q?", false, &mut all_yes).unwrap());
assert!(all_yes);
}
fn prompt_step_with_input(input: &str, yes: bool, all_yes: &mut bool) -> io::Result<bool> {
if yes || *all_yes {
return Ok(true);
}
match input.trim().to_lowercase().as_str() {
"y" => Ok(true),
"a" => {
*all_yes = true;
Ok(true)
}
_ => Ok(false),
}
}
#[test]
fn prompt_step_y_is_true() {
let mut all_yes = false;
assert!(prompt_step_with_input("y", false, &mut all_yes).unwrap());
assert!(!all_yes);
}
#[test]
fn prompt_step_a_is_true_and_sets_all_yes() {
let mut all_yes = false;
assert!(prompt_step_with_input("a", false, &mut all_yes).unwrap());
assert!(all_yes);
}
#[test]
fn prompt_step_n_is_false() {
let mut all_yes = false;
assert!(!prompt_step_with_input("n", false, &mut all_yes).unwrap());
}
#[test]
fn prompt_step_empty_is_false() {
let mut all_yes = false;
assert!(!prompt_step_with_input("", false, &mut all_yes).unwrap());
}
#[test]
fn prompt_step_no_is_false() {
let mut all_yes = false;
assert!(!prompt_step_with_input("no", false, &mut all_yes).unwrap());
}
#[test]
fn prompt_step_x_is_false() {
let mut all_yes = false;
assert!(!prompt_step_with_input("x", false, &mut all_yes).unwrap());
}
#[test]
fn prompt_step_uppercase_y_is_true() {
let mut all_yes = false;
assert!(prompt_step_with_input("Y", false, &mut all_yes).unwrap());
}
#[test]
fn prompt_step_uppercase_a_sets_all_yes() {
let mut all_yes = false;
assert!(prompt_step_with_input("A", false, &mut all_yes).unwrap());
assert!(all_yes);
}
impl Manifest {
fn default_for_tests() -> Self {
Manifest {
target: default_target(),
dirs: DirsSection::default(),
gitignore: GitignoreSection::default(),
root_markers: RootMarkersSection::default(),
memory: MemorySection::default(),
hymns: HymnsSection::default(),
}
}
}
}