use std::borrow::Cow;
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;
use crate::error::CliError;
use crate::home;
use crate::output::{self, OutputFormat, OutputSpec};
struct EmbeddedSkill {
name: &'static str,
body: &'static str,
path_in_repo: &'static str,
}
struct EmbeddedResource {
filename: &'static str,
body: &'static str,
claude_link_targets: &'static [&'static str],
}
const CODEX_SHARED_SUBDIR: &str = "_shared";
fn resources_for(name: &str) -> &'static [EmbeddedResource] {
match name {
"stint-start" => STINT_START_RESOURCES,
_ => &[],
}
}
const STINT_START_RESOURCES: &[EmbeddedResource] = &[EmbeddedResource {
filename: "AGENTS-EXECUTION-DAG.md",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/stint-start/AGENTS-EXECUTION-DAG.md"
)),
claude_link_targets: &[
"AGENTS-EXECUTION-DAG.md",
"../stint-start/AGENTS-EXECUTION-DAG.md",
],
}];
fn codex_link_target(filename: &str) -> String {
format!("{CODEX_SHARED_SUBDIR}/{filename}")
}
fn render_body_for_agent(agent: &str, body: &'static str) -> Cow<'static, str> {
if agent != "codex" {
return Cow::Borrowed(body);
}
let mut rendered = Cow::Borrowed(body);
for skill in SKILLS {
for resource in resources_for(skill.name) {
let codex_target = codex_link_target(resource.filename);
for claude_target in resource.claude_link_targets {
let from = format!("]({claude_target})");
if rendered.contains(&from) {
let to = format!("]({codex_target})");
rendered = Cow::Owned(rendered.replace(&from, &to));
}
}
}
}
rendered
}
fn codex_companion_path(skill_path: &Path, filename: &str) -> PathBuf {
let shared_dir = match skill_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(CODEX_SHARED_SUBDIR),
_ => PathBuf::from(CODEX_SHARED_SUBDIR),
};
shared_dir.join(filename)
}
const SKILLS: &[EmbeddedSkill] = &[
EmbeddedSkill {
name: "orchestratectl-overview",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/orchestratectl-overview/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/orchestratectl-overview/SKILL.template.md",
},
EmbeddedSkill {
name: "octl-run-overview",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/octl-run-overview/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/octl-run-overview/SKILL.template.md",
},
EmbeddedSkill {
name: "octl-spawn-spinoff",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/octl-spawn-spinoff/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/octl-spawn-spinoff/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-spinoff",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-spinoff/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-spinoff/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-code",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/worktree-code/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/worktree-code/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-merge",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/worktree-merge/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/worktree-merge/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-orchestrated",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-orchestrated/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-orchestrated/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-research",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-research/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-research/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-make-skill",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-make-skill/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-make-skill/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-bugfix",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/worktree-bugfix/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/worktree-bugfix/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-technical-decision",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-technical-decision/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-technical-decision/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-bug-analysis",
body: include_str!(concat!(
env!("OUT_DIR"),
"/skills/worktree-bug-analysis/SKILL.md"
)),
path_in_repo: "crates/octl-cli/skills/worktree-bug-analysis/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/worktree/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/worktree/SKILL.template.md",
},
EmbeddedSkill {
name: "worktree-status",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/worktree-status/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/worktree-status/SKILL.template.md",
},
EmbeddedSkill {
name: "stint-start",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/stint-start/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/stint-start/SKILL.template.md",
},
EmbeddedSkill {
name: "stint-handoff",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/stint-handoff/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/stint-handoff/SKILL.template.md",
},
EmbeddedSkill {
name: "fan-out",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/fan-out/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/fan-out/SKILL.template.md",
},
EmbeddedSkill {
name: "orchestrate",
body: include_str!(concat!(env!("OUT_DIR"), "/skills/orchestrate/SKILL.md")),
path_in_repo: "crates/octl-cli/skills/orchestrate/SKILL.template.md",
},
];
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
const SKILL_SCHEMA_VERSION: u32 = 1;
const MANAGED_MARKER_FILENAME: &str = ".orchestratectl-managed";
#[derive(Debug, Serialize)]
pub struct SkillCatalogEntry {
pub name: &'static str,
pub cli_version: String,
pub schema_version: u32,
}
pub fn catalog() -> Vec<SkillCatalogEntry> {
SKILLS
.iter()
.map(|s| SkillCatalogEntry {
name: s.name,
cli_version: parse_frontmatter_field(s.body, "cli_version")
.unwrap_or_else(|| CLI_VERSION.to_string()),
schema_version: parse_frontmatter_field(s.body, "schema_version")
.and_then(|v| v.parse().ok())
.unwrap_or(SKILL_SCHEMA_VERSION),
})
.collect()
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum AgentTarget {
Claude,
Codex,
All,
}
pub fn bundled_skill_names() -> Vec<&'static str> {
SKILLS.iter().map(|s| s.name).collect()
}
pub fn binary_cli_version() -> &'static str {
CLI_VERSION
}
pub fn claude_default_path(name: &str) -> Option<PathBuf> {
default_path("claude", name).ok()
}
pub fn codex_default_path(name: &str) -> Option<PathBuf> {
default_path("codex", name).ok()
}
pub fn read_on_disk_cli_version(path: &Path) -> Option<String> {
let body = fs::read_to_string(path).ok()?;
parse_frontmatter_field(&body, "cli_version")
}
pub fn cli_version_of(body: &str) -> Option<String> {
parse_frontmatter_field(body, "cli_version")
}
pub struct CompanionSource {
pub filename: &'static str,
pub bundled_body: &'static str,
}
pub fn companion_sources(name: &str) -> Vec<CompanionSource> {
resources_for(name)
.iter()
.map(|r| CompanionSource {
filename: r.filename,
bundled_body: r.body,
})
.collect()
}
pub fn all_companion_sources() -> Vec<CompanionSource> {
let mut seen: HashSet<&'static str> = HashSet::new();
let mut out: Vec<CompanionSource> = Vec::new();
for skill in SKILLS {
for r in resources_for(skill.name) {
if seen.insert(r.filename) {
out.push(CompanionSource {
filename: r.filename,
bundled_body: r.body,
});
}
}
}
out.sort_by(|a, b| a.filename.cmp(b.filename));
out
}
#[derive(Serialize)]
struct SkillSummary {
name: &'static str,
description: String,
}
#[derive(Serialize)]
struct ListPayload {
skills: Vec<SkillSummary>,
}
#[derive(Serialize)]
struct InstallPayload {
installed: Vec<InstalledFile>,
pruned: Vec<String>,
pruned_companions: Vec<String>,
}
#[derive(Serialize)]
struct InstalledFile {
name: &'static str,
agent: &'static str,
path: String,
}
pub fn cmd_list(spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
let skills: Vec<SkillSummary> = SKILLS
.iter()
.map(|s| SkillSummary {
name: s.name,
description: parse_description(s.body).unwrap_or_default(),
})
.collect();
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(&ListPayload { skills }, spec, warnings)?;
}
OutputFormat::Text => {
for s in &skills {
println!("{}\t{}", s.name, s.description);
}
output::emit_text_warnings(warnings);
}
}
Ok(())
}
pub fn cmd_show(name: &str, spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
let skill = lookup(name)?;
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
#[derive(Serialize)]
struct ShowPayload<'a> {
name: &'a str,
content: &'a str,
}
output::emit_envelope(
&ShowPayload {
name: skill.name,
content: skill.body,
},
spec,
warnings,
)?;
}
OutputFormat::Text => {
print!("{}", skill.body);
if !skill.body.ends_with('\n') {
println!();
}
output::emit_text_warnings(warnings);
}
}
Ok(())
}
pub fn cmd_print(name: &str, spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
let skill = lookup(name)?;
let cli_version = parse_frontmatter_field(skill.body, "cli_version")
.unwrap_or_else(|| CLI_VERSION.to_string());
let schema_version_skill = parse_frontmatter_field(skill.body, "schema_version")
.and_then(|v| v.parse().ok())
.unwrap_or(SKILL_SCHEMA_VERSION);
match spec.format {
OutputFormat::Json => {
#[derive(Serialize)]
struct PrintPayload<'a> {
schema_version: u32,
name: &'a str,
cli_version: &'a str,
schema_version_skill: u32,
content: &'a str,
path_in_repo: &'a str,
}
output::emit_envelope(
&PrintPayload {
schema_version: SKILL_SCHEMA_VERSION,
name: skill.name,
cli_version: &cli_version,
schema_version_skill,
content: skill.body,
path_in_repo: skill.path_in_repo,
},
spec,
warnings,
)?;
}
OutputFormat::Text | OutputFormat::Jsonl => {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
out.write_all(skill.body.as_bytes())
.map_err(|e| CliError::system("io_error", format!("write stdout: {e}")))?;
out.flush()
.map_err(|e| CliError::system("io_error", format!("flush stdout: {e}")))?;
output::emit_text_warnings(warnings);
}
}
Ok(())
}
pub fn cmd_install(
name: Option<&str>,
agent: AgentTarget,
dest: Option<PathBuf>,
force: bool,
spec: &OutputSpec,
warnings: &[String],
) -> Result<(), CliError> {
let skills: Vec<&'static EmbeddedSkill> = match name {
Some(n) => vec![lookup(n)?],
None => SKILLS.iter().collect(),
};
if dest.is_some() && matches!(agent, AgentTarget::All) {
return Err(CliError::user(
"invalid_arguments",
"--dest cannot be combined with --agent all",
));
}
if dest.is_some() && skills.len() > 1 {
return Err(CliError::user(
"invalid_arguments",
"--dest requires a skill name; omit --dest to install all skills",
));
}
let pi_dual_home = dest.is_none() && matches!(agent, AgentTarget::Claude | AgentTarget::All);
let mut pi_provenance: Option<(PathBuf, PiProvenance)> = None;
if pi_dual_home {
if let Some(record_path) = pi_provenance_path() {
let prov = load_pi_provenance_for_write(&record_path)?;
pi_provenance = Some((record_path, prov));
}
}
let mut plan: Vec<PlanItem> = Vec::new();
let mut mark_dirs: Vec<(&'static str, PathBuf)> = Vec::new();
for skill in &skills {
let targets: Vec<(&'static str, PathBuf)> = match (&agent, dest.as_ref()) {
(AgentTarget::Claude, Some(p)) => vec![("claude", p.clone())],
(AgentTarget::Codex, Some(p)) => vec![("codex", p.clone())],
(AgentTarget::Claude, None) => vec![("claude", default_path("claude", skill.name)?)],
(AgentTarget::Codex, None) => vec![("codex", default_path("codex", skill.name)?)],
(AgentTarget::All, _) => vec![
("claude", default_path("claude", skill.name)?),
("codex", default_path("codex", skill.name)?),
],
};
for (agent_name, path) in targets {
match agent_name {
"claude" => {
if dest.is_none() {
if let Some(parent) = path.parent() {
mark_dirs.push((skill.name, parent.to_path_buf()));
}
}
for resource in resources_for(skill.name) {
plan.push(PlanItem {
name: resource.filename,
agent: agent_name,
path: sibling_path(&path, resource.filename),
content: Cow::Borrowed(resource.body),
pi_companion_of: None,
});
}
}
"codex" => {
for resource in resources_for(skill.name) {
plan.push(PlanItem {
name: resource.filename,
agent: agent_name,
path: codex_companion_path(&path, resource.filename),
content: Cow::Borrowed(resource.body),
pi_companion_of: None,
});
}
}
_ => {}
}
let content = render_body_for_agent(agent_name, skill.body);
plan.push(PlanItem {
name: skill.name,
agent: agent_name,
path,
content,
pi_companion_of: None,
});
}
if dest.is_none() && matches!(agent, AgentTarget::Claude | AgentTarget::All) {
let pi_skill_path = default_path("pi", skill.name)?;
for resource in resources_for(skill.name) {
plan.push(PlanItem {
name: resource.filename,
agent: "pi",
path: sibling_path(&pi_skill_path, resource.filename),
content: Cow::Borrowed(resource.body),
pi_companion_of: Some(skill.name),
});
}
plan.push(PlanItem {
name: skill.name,
agent: "pi",
path: pi_skill_path,
content: Cow::Borrowed(skill.body),
pi_companion_of: None,
});
}
}
let preflight_result = preflight(&plan, force)?;
let mut all_warnings: Vec<String> = warnings.to_vec();
all_warnings.extend(preflight_result.warnings);
let mut installed = Vec::with_capacity(plan.len());
let mut pi_written: Vec<PiWrite> = Vec::new();
for item in plan {
if preflight_result.skipped.contains(&item.path) {
continue;
}
let allow_overwrite = preflight_result.overwrite_allowed.contains(&item.path);
write_atomic(&item.path, &item.content, allow_overwrite)?;
if item.agent == "pi" {
let hash = sha256_hex(item.content.as_bytes());
match item.pi_companion_of {
None => {
let cli_version = parse_frontmatter_field(&item.content, "cli_version")
.unwrap_or_else(|| CLI_VERSION.to_string());
pi_written.push(PiWrite::Skill {
name: item.name,
hash,
cli_version,
});
}
Some(owner) => {
pi_written.push(PiWrite::Companion {
owner,
filename: item.name,
hash,
});
}
}
}
installed.push(InstalledFile {
name: item.name,
agent: item.agent,
path: item.path.display().to_string(),
});
}
mark_dirs.sort();
mark_dirs.dedup();
let mut pruned_companions: Vec<String> = Vec::new();
for (skill_name, dir) in &mark_dirs {
let marker_path = dir.join(MANAGED_MARKER_FILENAME);
let bundled: Vec<&'static str> = resources_for(skill_name)
.iter()
.map(|r| r.filename)
.collect();
let mut recorded: Vec<String> = bundled.iter().copied().map(String::from).collect();
for prev in read_managed_companions(&marker_path) {
if bundled.iter().any(|b| *b == prev) {
continue; }
let orphan_path = dir.join(&prev);
let is_regular =
fs::symlink_metadata(&orphan_path).is_ok_and(|m| m.file_type().is_file());
if !is_regular {
continue;
}
if force {
match fs::remove_file(&orphan_path) {
Ok(()) => {
all_warnings.push(format!(
"skill_companion_pruned: removed orphan companion '{prev}' for skill '{skill_name}' at {}",
orphan_path.display()
));
pruned_companions.push(format!("{skill_name}/{prev}"));
}
Err(e) => {
all_warnings.push(format!(
"skill_companion_prune_failed: could not remove orphan companion '{prev}' for skill '{skill_name}' at {}: {e}",
orphan_path.display()
));
recorded.push(prev); }
}
} else {
recorded.push(prev); }
}
recorded.sort();
recorded.dedup();
write_marker(&marker_path, skill_name, &recorded)?;
}
let mut pruned: Vec<String> = Vec::new();
let prune_eligible = name.is_none()
&& dest.is_none()
&& force
&& matches!(agent, AgentTarget::Claude | AgentTarget::All);
if prune_eligible {
if let Some(root) = claude_skills_root() {
let registered: HashSet<&str> = SKILLS.iter().map(|s| s.name).collect();
for (orphan_name, orphan_path) in managed_orphan_dirs(&root, ®istered) {
match fs::remove_dir_all(&orphan_path) {
Ok(()) => {
all_warnings.push(format!(
"skill_pruned: removed de-registered managed skill '{orphan_name}' at {}",
orphan_path.display()
));
pruned.push(orphan_name);
}
Err(e) => {
all_warnings.push(format!(
"skill_prune_failed: could not remove de-registered skill '{orphan_name}' at {}: {e}",
orphan_path.display()
));
}
}
}
}
}
let codex_default = dest.is_none() && matches!(agent, AgentTarget::Codex | AgentTarget::All);
if codex_default {
if let (Some(prompts_root), Some(shared_root)) = (codex_prompts_root(), codex_shared_root())
{
let marker_path = shared_root.join(MANAGED_MARKER_FILENAME);
let mut recorded_prompts: HashSet<String> =
skills.iter().map(|s| s.name.to_string()).collect();
let mut recorded_companions: HashSet<String> = skills
.iter()
.flat_map(|s| resources_for(s.name))
.map(|r| r.filename.to_string())
.collect();
recorded_prompts.extend(read_marker_records(&marker_path, "prompt"));
recorded_companions.extend(read_marker_records(&marker_path, "companion"));
let codex_prune_eligible = name.is_none() && force;
if codex_prune_eligible {
let registered: HashSet<&str> = SKILLS.iter().map(|s| s.name).collect();
let bundled_companions: HashSet<&str> = SKILLS
.iter()
.flat_map(|s| resources_for(s.name))
.map(|r| r.filename)
.collect();
let orphan_prompts: Vec<String> = recorded_prompts
.iter()
.filter(|p| !registered.contains(p.as_str()))
.cloned()
.collect();
for orphan in orphan_prompts {
let prompt_path = prompts_root.join(format!("{orphan}.md"));
match prune_codex_file(
&prompt_path,
&format!("skill_pruned: removed de-registered managed codex prompt '{orphan}'"),
&format!("skill_prune_failed: could not remove de-registered codex prompt '{orphan}'"),
&mut all_warnings,
) {
CodexPruneOutcome::Removed => {
pruned.push(orphan.clone());
recorded_prompts.remove(&orphan);
}
CodexPruneOutcome::Dropped => {
recorded_prompts.remove(&orphan);
}
CodexPruneOutcome::Kept => {}
}
}
let orphan_companions: Vec<String> = recorded_companions
.iter()
.filter(|c| !bundled_companions.contains(c.as_str()))
.cloned()
.collect();
for orphan in orphan_companions {
let companion_path = shared_root.join(&orphan);
match prune_codex_file(
&companion_path,
&format!("skill_companion_pruned: removed orphan codex companion '_shared/{orphan}'"),
&format!("skill_companion_prune_failed: could not remove orphan codex companion '_shared/{orphan}'"),
&mut all_warnings,
) {
CodexPruneOutcome::Removed => {
pruned_companions.push(format!("{CODEX_SHARED_SUBDIR}/{orphan}"));
recorded_companions.remove(&orphan);
}
CodexPruneOutcome::Dropped => {
recorded_companions.remove(&orphan);
}
CodexPruneOutcome::Kept => {}
}
}
}
let mut prompts: Vec<String> = recorded_prompts.into_iter().collect();
prompts.sort();
let mut companions: Vec<String> = recorded_companions.into_iter().collect();
companions.sort();
fs::create_dir_all(&shared_root).map_err(|e| {
CliError::system(
"create_dir_failed",
format!("could not create {}: {}", shared_root.display(), e),
)
})?;
write_codex_marker(&marker_path, &prompts, &companions)?;
}
}
if let Some((record_path, mut prov)) = pi_provenance {
prov.schema_version = PI_PROVENANCE_SCHEMA_VERSION;
for w in &pi_written {
if let PiWrite::Skill {
name,
hash,
cli_version,
} = w
{
let rec = prov.skills.entry((*name).to_string()).or_default();
rec.sha256.clone_from(hash);
rec.cli_version.clone_from(cli_version);
}
}
for w in &pi_written {
if let PiWrite::Companion {
owner,
filename,
hash,
} = w
{
if let Some(rec) = prov.skills.get_mut(*owner) {
rec.companions.insert((*filename).to_string(), hash.clone());
} else {
all_warnings.push(format!(
"pi_companion_unrecorded: wrote pi companion '{filename}' for skill '{owner}' but no provenance record exists for it; it will not be tracked or pruned (reinstall the skill with --force to record it)"
));
}
}
}
for skill in &skills {
reconcile_pi_companions(
skill.name,
&mut prov,
force,
&mut pruned_companions,
&mut all_warnings,
);
}
if prune_eligible {
let registered: HashSet<&str> = SKILLS.iter().map(|s| s.name).collect();
let orphan_names: Vec<String> = prov
.skills
.keys()
.filter(|n| {
!registered.contains(n.as_str())
&& !registered.iter().any(|r| r.eq_ignore_ascii_case(n))
})
.cloned()
.collect();
for orphan in orphan_names {
if !is_simple_skill_name(&orphan) {
all_warnings.push(format!(
"pi_provenance_bad_name: ignoring pi provenance entry '{orphan}' (not a simple skill name)"
));
continue;
}
let record = prov.skills[&orphan].clone();
match prune_pi_mirror(
&orphan,
&record.sha256,
&record.companions,
&mut all_warnings,
) {
PiPruneOutcome::Removed => {
pruned.push(orphan.clone());
prov.skills.remove(&orphan);
}
PiPruneOutcome::Dropped | PiPruneOutcome::Diverged => {
prov.skills.remove(&orphan);
}
PiPruneOutcome::Kept => {}
}
}
}
write_pi_provenance(&record_path, &prov)?;
}
pruned.sort();
pruned.dedup();
let payload = InstallPayload {
installed,
pruned,
pruned_companions,
};
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(&payload, spec, &all_warnings)?;
}
OutputFormat::Text => {
for f in &payload.installed {
println!("installed {} ({}) -> {}", f.name, f.agent, f.path);
}
for name in &payload.pruned {
println!("pruned {name} (de-registered)");
}
for entry in &payload.pruned_companions {
println!("pruned {entry} (orphan companion)");
}
output::emit_text_warnings(&all_warnings);
}
}
Ok(())
}
fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
let av = semver::Version::parse(a).ok()?;
let bv = semver::Version::parse(b).ok()?;
Some(av.cmp(&bv))
}
struct PreflightResult {
warnings: Vec<String>,
overwrite_allowed: HashSet<PathBuf>,
skipped: HashSet<PathBuf>,
}
struct PlanItem {
name: &'static str,
agent: &'static str,
path: PathBuf,
content: Cow<'static, str>,
pi_companion_of: Option<&'static str>,
}
fn sibling_path(skill_path: &Path, filename: &str) -> PathBuf {
match skill_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(filename),
_ => PathBuf::from(filename),
}
}
fn preflight(plan: &[PlanItem], force: bool) -> Result<PreflightResult, CliError> {
use std::cmp::Ordering;
let mut seen: HashSet<&Path> = HashSet::new();
let mut warnings: Vec<String> = Vec::new();
let mut overwrite_allowed: HashSet<PathBuf> = HashSet::new();
let mut skipped: HashSet<PathBuf> = HashSet::new();
for PlanItem {
name,
agent,
path,
content,
..
} in plan
{
if !seen.insert(path.as_path()) {
return Err(CliError::user(
"duplicate_destination",
format!("destination appears more than once: {}", path.display()),
)
.with_invalid_value(path.display().to_string()));
}
if path.is_dir() {
return Err(CliError::user(
"invalid_dest",
format!("destination is a directory: {}", path.display()),
)
.with_invalid_value(path.display().to_string()));
}
if *agent == "pi" && path.exists() {
if force {
overwrite_allowed.insert(path.clone());
} else {
if !fs::read(path).is_ok_and(|b| b == content.as_bytes()) {
warnings.push(format!(
"pi_mirror_skipped: {name} already exists at {} and differs from the bundled copy; left unchanged (pass --force to refresh)",
path.display()
));
}
skipped.insert(path.clone());
}
continue;
}
if !path.exists() {
continue;
}
let on_disk_raw = fs::read_to_string(path)
.ok()
.and_then(|s| parse_frontmatter_field(&s, "cli_version"));
let drift = on_disk_raw
.as_deref()
.and_then(|v| compare_versions(v, CLI_VERSION).map(|ord| (v, ord)));
match drift {
Some((v, Ordering::Less)) => {
overwrite_allowed.insert(path.clone());
warnings.push(format!(
"skill_version_drift: {name} on disk is {v}; binary ships {CLI_VERSION}; overwriting"
));
}
Some((v, Ordering::Greater)) => {
if !force {
return Err(CliError::system(
"skill_version_too_new",
format!(
"{}: on-disk skill is cli_version {} but binary is {}; pass --force to overwrite anyway",
path.display(),
v,
CLI_VERSION
),
)
.with_invalid_value(path.display().to_string()));
}
overwrite_allowed.insert(path.clone());
warnings.push(format!(
"skill_version_drift: {name} on disk is {v} (newer than binary {CLI_VERSION}); --force overwriting"
));
}
Some((_, Ordering::Equal)) | None => {
if !force {
return Err(CliError::system(
"refused_overwrite",
format!(
"{} already exists; pass --force to overwrite",
path.display()
),
)
.with_invalid_value(path.display().to_string()));
}
overwrite_allowed.insert(path.clone());
}
}
}
Ok(PreflightResult {
warnings,
overwrite_allowed,
skipped,
})
}
fn lookup(name: &str) -> Result<&'static EmbeddedSkill, CliError> {
SKILLS.iter().find(|s| s.name == name).ok_or_else(|| {
let available: Vec<&str> = SKILLS.iter().map(|s| s.name).collect();
CliError::user(
"skill_not_found",
format!(
"no skill named '{}'; available: {}",
name,
available.join(", ")
),
)
.with_invalid_value(name.to_string())
.with_expected(serde_json::json!({ "one_of": available }))
})
}
fn default_path(agent: &str, name: &str) -> Result<PathBuf, CliError> {
let home = std::env::var("HOME").map_err(|_| {
CliError::system(
"home_unset",
"HOME is not set; cannot resolve default install path (pass --dest)",
)
})?;
let base = PathBuf::from(home);
Ok(match agent {
"claude" => base.join(".claude/skills").join(name).join("SKILL.md"),
"codex" => base.join(".codex/prompts").join(format!("{name}.md")),
"pi" => base.join(".pi/agent/skills").join(name).join("SKILL.md"),
other => {
return Err(CliError::user(
"invalid_agent",
format!("unknown agent '{other}'"),
))
}
})
}
pub fn claude_skills_root() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".claude/skills"))
}
pub fn codex_prompts_root() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".codex/prompts"))
}
pub fn codex_shared_root() -> Option<PathBuf> {
codex_prompts_root().map(|p| p.join(CODEX_SHARED_SUBDIR))
}
fn codex_marker_path() -> Option<PathBuf> {
codex_shared_root().map(|p| p.join(MANAGED_MARKER_FILENAME))
}
pub fn codex_managed_prompts() -> Vec<String> {
let Some(marker) = codex_marker_path() else {
return Vec::new();
};
let mut v = read_marker_records(&marker, "prompt");
v.sort();
v.dedup();
v
}
pub fn codex_managed_companions() -> Vec<String> {
let Some(marker) = codex_marker_path() else {
return Vec::new();
};
let mut v = read_marker_records(&marker, "companion");
v.sort();
v.dedup();
v
}
const PI_PROVENANCE_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct PiSkillRecord {
sha256: String,
cli_version: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
companions: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PiProvenance {
schema_version: u32,
skills: BTreeMap<String, PiSkillRecord>,
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
use std::fmt::Write as _;
let _ = write!(out, "{b:02x}");
}
out
}
fn pi_skills_root() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".pi/agent/skills"))
}
pub fn pi_default_path(name: &str) -> Option<PathBuf> {
default_path("pi", name).ok()
}
pub fn is_simple_skill_name(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(
(components.next(), components.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
fn pi_provenance_path() -> Option<PathBuf> {
home::root_dir()
.ok()
.map(|root| root.join("state").join("pi-installed-skills.json"))
}
fn read_pi_provenance(path: &Path) -> PiProvenance {
let Ok(body) = fs::read_to_string(path) else {
return PiProvenance::default();
};
match serde_json::from_str::<PiProvenance>(&body) {
Ok(p) if p.schema_version <= PI_PROVENANCE_SCHEMA_VERSION => p,
_ => PiProvenance::default(),
}
}
fn load_pi_provenance_for_write(path: &Path) -> Result<PiProvenance, CliError> {
let body = match fs::read_to_string(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(PiProvenance::default()),
Err(e) => {
return Err(CliError::system(
"pi_provenance_unreadable",
format!(
"could not read pi provenance record {}: {e}; back it up and remove it to re-initialise",
path.display()
),
))
}
};
let prov: PiProvenance = serde_json::from_str(&body).map_err(|e| {
CliError::system(
"pi_provenance_corrupt",
format!(
"pi provenance record {} is not valid JSON ({e}); refusing to overwrite it (that would erase tracking for every managed pi mirror). Back it up and remove it to re-initialise.",
path.display()
),
)
})?;
if prov.schema_version > PI_PROVENANCE_SCHEMA_VERSION {
return Err(CliError::system(
"pi_provenance_schema_too_new",
format!(
"pi provenance record {} uses schema {} but this binary understands only {}; refusing to overwrite it. Upgrade orchestratectl.",
path.display(),
prov.schema_version,
PI_PROVENANCE_SCHEMA_VERSION
),
));
}
Ok(prov)
}
fn write_pi_provenance(path: &Path, prov: &PiProvenance) -> Result<(), CliError> {
let body = serde_json::to_string_pretty(prov).map_err(|e| {
CliError::system(
"pi_provenance_serialize_failed",
format!("could not serialize pi provenance record: {e}"),
)
})?;
write_atomic(path, &body, true)
}
pub fn pi_managed_skills() -> Vec<PiManagedSkill> {
let Some(path) = pi_provenance_path() else {
return Vec::new();
};
let prov = read_pi_provenance(&path);
let mut out: Vec<PiManagedSkill> = prov
.skills
.into_iter()
.map(|(name, rec)| {
let mut companions: Vec<String> = rec.companions.into_keys().collect();
companions.sort();
PiManagedSkill {
name,
sha256: rec.sha256,
companions,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub struct PiManagedSkill {
pub name: String,
pub sha256: String,
pub companions: Vec<String>,
}
pub fn file_sha256(path: &Path) -> Option<String> {
fs::read(path).ok().map(|b| sha256_hex(&b))
}
fn is_managed_skill_dir(dir: &Path) -> bool {
let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) else {
return false;
};
let marker = dir.join(MANAGED_MARKER_FILENAME);
let Ok(meta) = fs::symlink_metadata(&marker) else {
return false;
};
if !meta.file_type().is_file() {
return false;
}
let Ok(content) = fs::read_to_string(&marker) else {
return false;
};
let mut has_stamp = false;
let mut name_matches = false;
for line in content.lines() {
let line = line.trim();
if line == "managed-by: orchestratectl" {
has_stamp = true;
} else if let Some(rest) = line.strip_prefix("skill_name:") {
name_matches = rest.trim() == dir_name;
}
}
has_stamp && name_matches
}
fn write_marker(path: &Path, skill_name: &str, companions: &[String]) -> Result<(), CliError> {
clear_marker_symlink(path)?;
let mut body = format!(
"managed-by: orchestratectl\ncli_version: {CLI_VERSION}\nskill_name: {skill_name}\n"
);
for companion in companions {
body.push_str("companion: ");
body.push_str(companion);
body.push('\n');
}
fs::write(path, body).map_err(|e| {
CliError::system(
"marker_write_failed",
format!(
"could not write provenance marker {}: {}",
path.display(),
e
),
)
})
}
fn write_codex_marker(
path: &Path,
prompts: &[String],
companions: &[String],
) -> Result<(), CliError> {
clear_marker_symlink(path)?;
let mut body = format!("managed-by: orchestratectl\ncli_version: {CLI_VERSION}\n");
for prompt in prompts {
body.push_str("prompt: ");
body.push_str(prompt);
body.push('\n');
}
for companion in companions {
body.push_str("companion: ");
body.push_str(companion);
body.push('\n');
}
fs::write(path, body).map_err(|e| {
CliError::system(
"marker_write_failed",
format!(
"could not write codex provenance marker {}: {}",
path.display(),
e
),
)
})
}
fn clear_marker_symlink(path: &Path) -> Result<(), CliError> {
if let Ok(meta) = fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
fs::remove_file(path).map_err(|e| {
CliError::system(
"marker_write_failed",
format!(
"could not clear stale marker symlink {}: {}",
path.display(),
e
),
)
})?;
}
}
Ok(())
}
fn managed_orphan_dirs(skills_root: &Path, registered: &HashSet<&str>) -> Vec<(String, PathBuf)> {
let Ok(entries) = fs::read_dir(skills_root) else {
return Vec::new();
};
let mut orphans: Vec<(String, PathBuf)> = Vec::new();
for entry in entries {
let Ok(entry) = entry else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let path = entry.path();
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if registered
.iter()
.any(|r| *r == dir_name || r.eq_ignore_ascii_case(dir_name))
{
continue;
}
if is_managed_skill_dir(&path) {
orphans.push((dir_name.to_string(), path.clone()));
}
}
orphans.sort();
orphans
}
enum CodexPruneOutcome {
Removed,
Dropped,
Kept,
}
fn prune_codex_file(
path: &Path,
removed_warning: &str,
failed_warning: &str,
warnings: &mut Vec<String>,
) -> CodexPruneOutcome {
let is_regular = fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
if !is_regular {
return CodexPruneOutcome::Dropped;
}
match fs::remove_file(path) {
Ok(()) => {
warnings.push(format!("{removed_warning} at {}", path.display()));
CodexPruneOutcome::Removed
}
Err(e) => {
warnings.push(format!("{failed_warning} at {}: {e}", path.display()));
CodexPruneOutcome::Kept
}
}
}
enum PiWrite {
Skill {
name: &'static str,
hash: String,
cli_version: String,
},
Companion {
owner: &'static str,
filename: &'static str,
hash: String,
},
}
enum PiPruneOutcome {
Removed,
Dropped,
Diverged,
Kept,
}
fn prune_pi_mirror(
name: &str,
recorded_hash: &str,
companions: &BTreeMap<String, String>,
warnings: &mut Vec<String>,
) -> PiPruneOutcome {
let Some(path) = pi_default_path(name) else {
return PiPruneOutcome::Dropped;
};
prune_pi_mirror_at(
name,
&path,
pi_skills_root().as_deref(),
recorded_hash,
companions,
warnings,
)
}
fn prune_pi_mirror_at(
name: &str,
path: &Path,
skills_root: Option<&Path>,
recorded_hash: &str,
companions: &BTreeMap<String, String>,
warnings: &mut Vec<String>,
) -> PiPruneOutcome {
let is_regular = fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
if is_regular {
match fs::read(path) {
Ok(bytes) => {
if sha256_hex(&bytes) != recorded_hash {
warnings.push(format!(
"pi_mirror_diverged: de-registered pi mirror '{name}' at {} was modified since orchestratectl wrote it; leaving it in place and no longer tracking it",
path.display()
));
return PiPruneOutcome::Diverged;
}
}
Err(e) => {
warnings.push(format!(
"pi_mirror_prune_failed: could not read de-registered pi mirror '{name}' at {}: {e}",
path.display()
));
return PiPruneOutcome::Kept;
}
}
}
let mut any_companion_kept = false;
if let Some(dir) = path.parent() {
for (filename, chash) in companions {
if !is_simple_skill_name(filename) {
warnings.push(format!(
"pi_provenance_bad_name: ignoring pi companion entry '{filename}' for skill '{name}' (not a simple filename)"
));
continue;
}
if matches!(
prune_pi_companion(name, &dir.join(filename), chash, warnings),
PiCompanionOutcome::Kept
) {
any_companion_kept = true;
}
}
}
if any_companion_kept {
return PiPruneOutcome::Kept;
}
if !is_regular {
remove_empty_pi_skill_dir(path.parent(), skills_root, name);
return PiPruneOutcome::Dropped;
}
match fs::remove_file(path) {
Ok(()) => {
remove_empty_pi_skill_dir(path.parent(), skills_root, name);
warnings.push(format!(
"pi_mirror_pruned: removed de-registered pi mirror '{name}' at {}",
path.display()
));
PiPruneOutcome::Removed
}
Err(e) => {
warnings.push(format!(
"pi_mirror_prune_failed: could not remove de-registered pi mirror '{name}' at {}: {e}",
path.display()
));
PiPruneOutcome::Kept
}
}
}
fn remove_empty_pi_skill_dir(parent: Option<&Path>, skills_root: Option<&Path>, name: &str) {
if let (Some(parent), Some(root)) = (parent, skills_root) {
if parent.parent() == Some(root)
&& parent.file_name().and_then(|n| n.to_str()) == Some(name)
{
let _ = fs::remove_dir(parent);
}
}
}
#[derive(PartialEq, Eq)]
enum PiCompanionOutcome {
Removed,
Absent,
NonRegular,
Diverged,
Kept,
}
fn prune_pi_companion(
skill: &str,
path: &Path,
recorded_hash: &str,
warnings: &mut Vec<String>,
) -> PiCompanionOutcome {
match fs::symlink_metadata(path) {
Err(_) => return PiCompanionOutcome::Absent,
Ok(m) if !m.file_type().is_file() => {
warnings.push(format!(
"pi_companion_left: companion of de-registered pi skill '{skill}' at {} is not a regular file (symlink or directory); leaving it in place",
path.display()
));
return PiCompanionOutcome::NonRegular;
}
Ok(_) => {}
}
match fs::read(path) {
Ok(bytes) if sha256_hex(&bytes) == recorded_hash => match fs::remove_file(path) {
Ok(()) => {
warnings.push(format!(
"pi_companion_pruned: removed companion of de-registered pi skill '{skill}' at {}",
path.display()
));
PiCompanionOutcome::Removed
}
Err(e) => {
warnings.push(format!(
"pi_companion_prune_failed: could not remove companion of de-registered pi skill '{skill}' at {}: {e}",
path.display()
));
PiCompanionOutcome::Kept
}
},
Ok(_) => {
warnings.push(format!(
"pi_companion_diverged: companion of de-registered pi skill '{skill}' at {} was modified since orchestratectl wrote it; leaving it in place",
path.display()
));
PiCompanionOutcome::Diverged
}
Err(e) => {
warnings.push(format!(
"pi_companion_prune_failed: could not read companion of de-registered pi skill '{skill}' at {}: {e}",
path.display()
));
PiCompanionOutcome::Kept
}
}
}
fn reconcile_pi_companions(
skill_name: &str,
prov: &mut PiProvenance,
force: bool,
pruned_companions: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
let Some(rec) = prov.skills.get_mut(skill_name) else {
return;
};
let Some(dir) = pi_default_path(skill_name).and_then(|p| p.parent().map(Path::to_path_buf))
else {
return;
};
reconcile_pi_companions_at(skill_name, rec, &dir, force, pruned_companions, warnings);
}
fn reconcile_pi_companions_at(
skill_name: &str,
rec: &mut PiSkillRecord,
dir: &Path,
force: bool,
pruned_companions: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
let bundled: HashSet<&str> = resources_for(skill_name)
.iter()
.map(|r| r.filename)
.collect();
let stale: Vec<String> = rec
.companions
.keys()
.filter(|f| !bundled.contains(f.as_str()))
.cloned()
.collect();
if stale.is_empty() {
return;
}
if !force {
return;
}
for filename in stale {
if !is_simple_skill_name(&filename) {
warnings.push(format!(
"pi_provenance_bad_name: ignoring pi companion entry '{filename}' for skill '{skill_name}' (not a simple filename)"
));
rec.companions.remove(&filename);
continue;
}
let recorded_hash = rec.companions[&filename].clone();
let path = dir.join(&filename);
let is_our_copy = fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file())
&& fs::read(&path).is_ok_and(|b| sha256_hex(&b) == recorded_hash);
if is_our_copy {
match fs::remove_file(&path) {
Ok(()) => {
warnings.push(format!(
"skill_companion_pruned: removed orphan pi companion '{filename}' for skill '{skill_name}' at {}",
path.display()
));
pruned_companions.push(format!("{skill_name}/{filename}"));
rec.companions.remove(&filename);
}
Err(e) => {
warnings.push(format!(
"skill_companion_prune_failed: could not remove orphan pi companion '{filename}' for skill '{skill_name}' at {}: {e}",
path.display()
));
}
}
} else {
warnings.push(format!(
"pi_companion_relinquished: orphan pi companion '{filename}' for skill '{skill_name}' at {} is not our unmodified copy; no longer tracking it",
path.display()
));
rec.companions.remove(&filename);
}
}
}
pub fn managed_orphans() -> Vec<(String, PathBuf)> {
let Some(root) = claude_skills_root() else {
return Vec::new();
};
let registered: HashSet<&str> = SKILLS.iter().map(|s| s.name).collect();
managed_orphan_dirs(&root, ®istered)
}
fn read_managed_companions(marker_path: &Path) -> Vec<String> {
read_marker_records(marker_path, "companion")
}
fn read_marker_records(marker_path: &Path, key: &str) -> Vec<String> {
let Ok(content) = fs::read_to_string(marker_path) else {
return Vec::new();
};
let prefix = format!("{key}:");
content
.lines()
.filter_map(|line| line.trim().strip_prefix(prefix.as_str()))
.map(|rest| rest.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
pub fn orphan_companions(skill_name: &str, skill_dir: &Path) -> Vec<String> {
let bundled: HashSet<&str> = resources_for(skill_name)
.iter()
.map(|r| r.filename)
.collect();
let marker_path = skill_dir.join(MANAGED_MARKER_FILENAME);
let mut orphans: Vec<String> = read_managed_companions(&marker_path)
.into_iter()
.filter(|name| !bundled.contains(name.as_str()))
.filter(|name| fs::symlink_metadata(skill_dir.join(name)).is_ok())
.collect();
orphans.sort();
orphans.dedup();
orphans
}
fn normalized_parent(path: &Path) -> Option<&Path> {
match path.parent() {
Some(p) if p.as_os_str().is_empty() => Some(Path::new(".")),
Some(p) => Some(p),
None => None,
}
}
fn write_atomic(path: &Path, content: &str, force: bool) -> Result<(), CliError> {
let parent = normalized_parent(path).ok_or_else(|| {
CliError::user(
"invalid_dest",
format!("destination has no parent directory: {}", path.display()),
)
})?;
fs::create_dir_all(parent).map_err(|e| {
CliError::system(
"create_dir_failed",
format!("could not create {}: {}", parent.display(), e),
)
})?;
let mut tmp = NamedTempFile::new_in(parent).map_err(|e| {
CliError::system(
"tempfile_failed",
format!("could not create tempfile in {}: {}", parent.display(), e),
)
})?;
tmp.write_all(content.as_bytes())
.map_err(|e| CliError::system("write_failed", format!("could not write tempfile: {e}")))?;
tmp.as_file_mut()
.sync_all()
.map_err(|e| CliError::system("fsync_failed", format!("could not fsync tempfile: {e}")))?;
let persist_result = if force {
tmp.persist(path).map(|_| ())
} else {
tmp.persist_noclobber(path).map(|_| ())
};
persist_result.map_err(|e| {
let kind = e.error.kind();
if !force && kind == std::io::ErrorKind::AlreadyExists {
CliError::system(
"refused_overwrite",
format!(
"{} already exists; pass --force to overwrite",
path.display()
),
)
.with_invalid_value(path.display().to_string())
} else {
CliError::system(
"rename_failed",
format!("could not rename into place {}: {}", path.display(), e),
)
}
})
}
fn parse_description(body: &str) -> Option<String> {
parse_frontmatter_field(body, "description")
}
fn parse_frontmatter_field(body: &str, field: &str) -> Option<String> {
let body = body.strip_prefix('\u{feff}').unwrap_or(body);
let mut lines = body.lines();
if lines.next()?.trim_end() != "---" {
return None;
}
for line in lines {
if line.trim_end() == "---" {
return None;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.trim() == field {
let v = value.trim();
let v = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(v);
return Some(v.to_string());
}
}
None
}
#[cfg(test)]
fn parse_name(body: &str) -> Option<String> {
parse_frontmatter_field(body, "name")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_embedded_skill_has_a_description_and_matching_name() {
for s in SKILLS {
let d = parse_description(s.body)
.unwrap_or_else(|| panic!("skill {} missing description", s.name));
assert!(!d.is_empty(), "skill {} has empty description", s.name);
let n = parse_name(s.body).unwrap_or_else(|| panic!("skill {} missing name", s.name));
assert_eq!(
n, s.name,
"catalog name {:?} does not match frontmatter name {:?}",
s.name, n
);
let cli = parse_frontmatter_field(s.body, "cli_version")
.unwrap_or_else(|| panic!("skill {} missing cli_version", s.name));
assert_eq!(
cli, CLI_VERSION,
"skill {} cli_version {:?} does not match binary {:?}",
s.name, cli, CLI_VERSION
);
let schema = parse_frontmatter_field(s.body, "schema_version")
.unwrap_or_else(|| panic!("skill {} missing schema_version", s.name));
let parsed: u32 = schema.parse().unwrap_or_else(|_| {
panic!(
"skill {} has unparseable schema_version {:?}",
s.name, schema
)
});
assert_eq!(
parsed, SKILL_SCHEMA_VERSION,
"skill {} schema_version {} != {}",
s.name, parsed, SKILL_SCHEMA_VERSION
);
assert!(
!s.body.contains("{{CLI_VERSION}}"),
"skill {} still contains unrendered {{{{CLI_VERSION}}}} placeholder",
s.name
);
}
}
#[test]
fn every_companion_resource_is_rendered_and_version_pinned() {
for name in SKILLS.iter().map(|s| s.name) {
for r in resources_for(name) {
assert!(
!r.body.contains("{{CLI_VERSION}}"),
"companion {} for skill {} still contains an unrendered {{{{CLI_VERSION}}}} placeholder",
r.filename,
name
);
if let Some(v) = parse_frontmatter_field(r.body, "cli_version") {
assert_eq!(
v, CLI_VERSION,
"companion {} for skill {} declares cli_version {:?}, binary is {:?}",
r.filename, name, v, CLI_VERSION
);
}
}
}
}
#[test]
fn every_claude_link_target_appears_in_some_skill_body() {
for skill in SKILLS {
for r in resources_for(skill.name) {
for target in r.claude_link_targets {
let needle = format!("]({target})");
assert!(
SKILLS.iter().any(|s| s.body.contains(&needle)),
"no skill body contains link {needle:?} declared for companion {} of {}",
r.filename,
skill.name
);
}
}
}
}
#[test]
fn render_body_for_claude_is_byte_identical_and_borrowed() {
for s in SKILLS {
let rendered = render_body_for_agent("claude", s.body);
assert!(
matches!(rendered, Cow::Borrowed(_)),
"claude body for {} was reallocated",
s.name
);
assert_eq!(
&*rendered, s.body,
"claude body for {} was modified",
s.name
);
}
}
#[test]
fn render_body_for_codex_rewrites_both_companion_link_forms() {
let start = SKILLS.iter().find(|s| s.name == "stint-start").unwrap();
let handoff = SKILLS.iter().find(|s| s.name == "stint-handoff").unwrap();
let start_codex = render_body_for_agent("codex", start.body);
let handoff_codex = render_body_for_agent("codex", handoff.body);
assert!(start_codex.contains("](_shared/AGENTS-EXECUTION-DAG.md)"));
assert!(!start_codex.contains("](AGENTS-EXECUTION-DAG.md)"));
assert!(handoff_codex.contains("](_shared/AGENTS-EXECUTION-DAG.md)"));
assert!(!handoff_codex.contains("](../stint-start/AGENTS-EXECUTION-DAG.md)"));
}
#[test]
fn render_body_for_codex_without_companion_links_is_noop() {
let no_links = SKILLS.iter().find(|s| s.name == "worktree-code").unwrap();
let rendered = render_body_for_agent("codex", no_links.body);
assert!(matches!(rendered, Cow::Borrowed(_)));
assert_eq!(&*rendered, no_links.body);
}
#[test]
fn codex_companion_path_derives_shared_subdir() {
assert_eq!(
codex_companion_path(Path::new("/home/u/.codex/prompts/stint-start.md"), "X.md"),
PathBuf::from("/home/u/.codex/prompts/_shared/X.md")
);
assert_eq!(
codex_companion_path(Path::new("out/prompts/s.md"), "X.md"),
PathBuf::from("out/prompts/_shared/X.md")
);
assert_eq!(
codex_companion_path(Path::new("s.md"), "X.md"),
PathBuf::from("_shared/X.md")
);
}
#[test]
fn write_marker_records_companions_read_back() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join(MANAGED_MARKER_FILENAME);
write_marker(
&marker,
"stint-start",
&["A.md".to_string(), "B.md".to_string()],
)
.unwrap();
let recorded = read_managed_companions(&marker);
assert_eq!(recorded, vec!["A.md".to_string(), "B.md".to_string()]);
assert!(read_managed_companions(&dir.path().join("nope")).is_empty());
}
#[test]
fn orphan_companions_flags_dropped_but_not_bundled() {
let skill = "stint-start";
let bundled: Vec<&str> = resources_for(skill).iter().map(|r| r.filename).collect();
assert!(
!bundled.is_empty(),
"test assumes stint-start ships >=1 companion"
);
let dir = tempfile::tempdir().unwrap();
let mut recorded: Vec<String> = bundled.iter().copied().map(String::from).collect();
recorded.push("OLD-COMPANION.md".to_string());
write_marker(&dir.path().join(MANAGED_MARKER_FILENAME), skill, &recorded).unwrap();
for f in &bundled {
fs::write(dir.path().join(f), "x").unwrap();
}
fs::write(dir.path().join("OLD-COMPANION.md"), "stale").unwrap();
let orphans = orphan_companions(skill, dir.path());
assert_eq!(
orphans,
vec!["OLD-COMPANION.md".to_string()],
"only the dropped-but-recorded companion is an orphan"
);
}
#[test]
fn orphan_companions_ignores_unrecorded_user_file() {
let skill = "stint-start";
let bundled: Vec<String> = resources_for(skill)
.iter()
.map(|r| r.filename.to_string())
.collect();
let dir = tempfile::tempdir().unwrap();
write_marker(&dir.path().join(MANAGED_MARKER_FILENAME), skill, &bundled).unwrap();
fs::write(dir.path().join("my-note.md"), "mine").unwrap();
assert!(
orphan_companions(skill, dir.path()).is_empty(),
"an unrecorded user file is not an orphan"
);
}
#[test]
fn orphan_companions_ignores_recorded_but_absent_file() {
let skill = "stint-start";
let bundled: Vec<String> = resources_for(skill)
.iter()
.map(|r| r.filename.to_string())
.collect();
let dir = tempfile::tempdir().unwrap();
let mut recorded = bundled.clone();
recorded.push("GONE.md".to_string());
write_marker(&dir.path().join(MANAGED_MARKER_FILENAME), skill, &recorded).unwrap();
assert!(orphan_companions(skill, dir.path()).is_empty());
}
#[test]
fn codex_marker_records_prompts_and_companions_read_back() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join(MANAGED_MARKER_FILENAME);
write_codex_marker(
&marker,
&["stint-start".to_string(), "worktree-code".to_string()],
&["AGENTS-EXECUTION-DAG.md".to_string()],
)
.unwrap();
assert_eq!(
read_marker_records(&marker, "prompt"),
vec!["stint-start".to_string(), "worktree-code".to_string()]
);
assert_eq!(
read_marker_records(&marker, "companion"),
vec!["AGENTS-EXECUTION-DAG.md".to_string()]
);
let missing = dir.path().join("nope");
assert!(read_marker_records(&missing, "prompt").is_empty());
assert!(read_marker_records(&missing, "companion").is_empty());
}
#[test]
fn all_companion_sources_dedupes_by_filename() {
let sources = all_companion_sources();
let mut names: Vec<&str> = sources.iter().map(|c| c.filename).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(
names, sorted,
"companion sources must be sorted by filename"
);
names.dedup();
assert_eq!(
names.len(),
sources.len(),
"companion filenames must be unique across skills"
);
assert!(sources
.iter()
.any(|c| c.filename == "AGENTS-EXECUTION-DAG.md"));
}
#[test]
fn prune_codex_file_outcomes() {
let dir = tempfile::tempdir().unwrap();
let mut warnings: Vec<String> = Vec::new();
let regular = dir.path().join("gone.md");
fs::write(®ular, "stale").unwrap();
assert!(matches!(
prune_codex_file(®ular, "removed", "failed", &mut warnings),
CodexPruneOutcome::Removed
));
assert!(!regular.exists(), "file must be gone after Removed");
assert!(warnings[0].starts_with("removed at "));
let absent = dir.path().join("never.md");
assert!(matches!(
prune_codex_file(&absent, "removed", "failed", &mut warnings),
CodexPruneOutcome::Dropped
));
let squat = dir.path().join("squat.md");
fs::create_dir(&squat).unwrap();
assert!(matches!(
prune_codex_file(&squat, "removed", "failed", &mut warnings),
CodexPruneOutcome::Dropped
));
assert!(squat.is_dir(), "a squatting dir must be left intact");
}
#[test]
fn compare_versions_handles_semver_ordering() {
use std::cmp::Ordering;
assert_eq!(
compare_versions("1.0.0-alpha", "1.0.0"),
Some(Ordering::Less)
);
assert_eq!(
compare_versions("1.0.0", "1.0.0-alpha"),
Some(Ordering::Greater)
);
assert_eq!(compare_versions("1.10.0", "1.9.0"), Some(Ordering::Greater));
assert_eq!(compare_versions("0.0.1", "0.0.1"), Some(Ordering::Equal));
assert_eq!(compare_versions("banana", "1.0.0"), None);
assert_eq!(compare_versions("1.0.0", "1.x"), None);
assert_eq!(compare_versions("{{CLI_VERSION}}", "1.0.0"), None);
}
#[test]
fn parse_description_extracts_value() {
let body = "---\nname: foo\ndescription: a short blurb\nversion: 1\n---\n\n# body\n";
assert_eq!(parse_description(body).as_deref(), Some("a short blurb"));
}
#[test]
fn parse_description_handles_crlf() {
let body = "---\r\nname: foo\r\ndescription: blurb\r\n---\r\n";
assert_eq!(parse_description(body).as_deref(), Some("blurb"));
}
#[test]
fn parse_description_strips_quotes() {
let body = "---\ndescription: \"quoted blurb\"\n---\n";
assert_eq!(parse_description(body).as_deref(), Some("quoted blurb"));
}
#[test]
fn parse_description_returns_none_without_frontmatter() {
assert_eq!(parse_description("# just a heading\n"), None);
}
#[test]
fn parse_description_returns_none_when_field_absent() {
let body = "---\nname: foo\nversion: 1\n---\n";
assert_eq!(parse_description(body), None);
}
#[test]
fn sha256_hex_is_deterministic_and_lowercase() {
let a = sha256_hex(b"hello");
assert_eq!(a, sha256_hex(b"hello"));
assert_ne!(a, sha256_hex(b"world"));
assert_eq!(a.len(), 64);
assert!(a
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
assert_eq!(
a,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn pi_provenance_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state").join("pi-installed-skills.json");
let mut prov = PiProvenance {
schema_version: PI_PROVENANCE_SCHEMA_VERSION,
skills: BTreeMap::new(),
};
prov.skills.insert(
"stint-start".to_string(),
PiSkillRecord {
sha256: sha256_hex(b"body-a"),
cli_version: "0.1.7".to_string(),
companions: BTreeMap::new(),
},
);
write_pi_provenance(&path, &prov).unwrap();
let read_back = read_pi_provenance(&path);
assert_eq!(read_back.schema_version, PI_PROVENANCE_SCHEMA_VERSION);
assert_eq!(
read_back
.skills
.get("stint-start")
.map(|r| r.sha256.clone()),
Some(sha256_hex(b"body-a"))
);
}
#[test]
fn read_pi_provenance_tolerates_missing_and_garbage() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.json");
assert!(read_pi_provenance(&missing).skills.is_empty());
let garbage = dir.path().join("garbage.json");
fs::write(&garbage, "{ not json").unwrap();
assert!(read_pi_provenance(&garbage).skills.is_empty());
let future = dir.path().join("future.json");
fs::write(&future, r#"{"schema_version":999,"skills":{}}"#).unwrap();
assert!(read_pi_provenance(&future).skills.is_empty());
}
#[test]
fn load_pi_provenance_for_write_fails_closed_on_corruption() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.json");
assert!(load_pi_provenance_for_write(&missing)
.unwrap()
.skills
.is_empty());
let garbage = dir.path().join("garbage.json");
fs::write(&garbage, "{ not json").unwrap();
let err = load_pi_provenance_for_write(&garbage).unwrap_err();
assert_eq!(err.code, "pi_provenance_corrupt");
let future = dir.path().join("future.json");
fs::write(&future, r#"{"schema_version":999,"skills":{}}"#).unwrap();
let err = load_pi_provenance_for_write(&future).unwrap_err();
assert_eq!(err.code, "pi_provenance_schema_too_new");
}
#[test]
fn is_simple_skill_name_rejects_traversal_and_absolute() {
assert!(is_simple_skill_name("stint-start"));
assert!(is_simple_skill_name("worktree-code"));
assert!(!is_simple_skill_name("../../.bashrc"));
assert!(!is_simple_skill_name("a/b"));
assert!(!is_simple_skill_name("/etc/passwd"));
assert!(!is_simple_skill_name(".."));
assert!(!is_simple_skill_name(""));
}
#[test]
fn write_pi_provenance_replaces_squatting_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("victim.txt");
fs::write(&target, "precious").unwrap();
let record = dir.path().join("pi-installed-skills.json");
std::os::unix::fs::symlink(&target, &record).unwrap();
let prov = PiProvenance {
schema_version: PI_PROVENANCE_SCHEMA_VERSION,
skills: BTreeMap::new(),
};
write_pi_provenance(&record, &prov).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
assert!(fs::symlink_metadata(&record).unwrap().file_type().is_file());
}
#[test]
fn prune_pi_mirror_removes_our_unmodified_copy_and_empty_dir() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md");
let body = b"---\nname: old-skill\n---\nbody\n";
fs::write(&mirror, body).unwrap();
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(body),
&BTreeMap::new(),
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Removed));
assert!(!mirror.exists(), "mirror file must be gone");
assert!(
!skill_dir.exists(),
"empty per-skill dir must be cleaned up"
);
assert!(warnings[0].starts_with("pi_mirror_pruned:"));
}
#[test]
fn prune_pi_mirror_removes_recorded_companions_then_empty_dir() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md");
let body = b"---\nname: old-skill\n---\nbody\n";
fs::write(&mirror, body).unwrap();
let comp_body = b"companion payload\n";
fs::write(skill_dir.join("AGENTS-EXECUTION-DAG.md"), comp_body).unwrap();
fs::write(skill_dir.join("EDITED.md"), b"user changed this").unwrap();
let mut companions = BTreeMap::new();
companions.insert("AGENTS-EXECUTION-DAG.md".to_string(), sha256_hex(comp_body));
companions.insert("EDITED.md".to_string(), sha256_hex(b"original edited body"));
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(body),
&companions,
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Removed));
assert!(!mirror.exists(), "SKILL.md removed");
assert!(
!skill_dir.join("AGENTS-EXECUTION-DAG.md").exists(),
"our unmodified companion is removed"
);
assert!(
skill_dir.join("EDITED.md").exists(),
"a diverged companion is preserved"
);
assert!(skill_dir.exists(), "dir with a surviving companion is kept");
assert!(warnings
.iter()
.any(|w| w.starts_with("pi_companion_pruned:")));
assert!(warnings
.iter()
.any(|w| w.starts_with("pi_companion_diverged:")));
}
#[test]
fn prune_pi_mirror_removes_all_matching_companions_and_empty_dir() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md");
let body = b"body\n";
fs::write(&mirror, body).unwrap();
let c1 = b"c1\n";
let c2 = b"c2\n";
fs::write(skill_dir.join("A.md"), c1).unwrap();
fs::write(skill_dir.join("B.md"), c2).unwrap();
let mut companions = BTreeMap::new();
companions.insert("A.md".to_string(), sha256_hex(c1));
companions.insert("B.md".to_string(), sha256_hex(c2));
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(body),
&companions,
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Removed));
assert!(!skill_dir.exists(), "fully-cleaned dir must be removed");
}
#[test]
fn prune_pi_mirror_cleans_companions_when_skill_md_absent() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md"); let comp = b"companion\n";
fs::write(skill_dir.join("C.md"), comp).unwrap();
let mut companions = BTreeMap::new();
companions.insert("C.md".to_string(), sha256_hex(comp));
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
"irrelevant-body-hash",
&companions,
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Dropped));
assert!(
!skill_dir.join("C.md").exists(),
"recorded companion cleaned even with an absent SKILL.md"
);
assert!(!skill_dir.exists(), "now-empty dir removed");
assert!(warnings
.iter()
.any(|w| w.starts_with("pi_companion_pruned:")));
}
#[test]
fn prune_pi_mirror_diverged_body_leaves_companions_untouched() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md");
fs::write(&mirror, b"user edited body").unwrap();
let comp = b"companion\n";
fs::write(skill_dir.join("C.md"), comp).unwrap();
let mut companions = BTreeMap::new();
companions.insert("C.md".to_string(), sha256_hex(comp));
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(b"the body we originally wrote"),
&companions,
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Diverged));
assert!(mirror.exists(), "diverged body is left in place");
assert!(
skill_dir.join("C.md").exists(),
"companions are left untouched when the body diverged"
);
}
#[test]
fn reconcile_pi_companions_force_removes_dropped_companion() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
let dag = b"dag body\n";
let old = b"old body\n";
fs::write(dir.join("AGENTS-EXECUTION-DAG.md"), dag).unwrap();
fs::write(dir.join("OLD.md"), old).unwrap();
let mut rec = PiSkillRecord {
sha256: sha256_hex(b"skill body"),
cli_version: CLI_VERSION.to_string(),
companions: BTreeMap::new(),
};
rec.companions
.insert("AGENTS-EXECUTION-DAG.md".to_string(), sha256_hex(dag));
rec.companions.insert("OLD.md".to_string(), sha256_hex(old));
let mut pruned = Vec::new();
let mut warnings = Vec::new();
reconcile_pi_companions_at(
"stint-start",
&mut rec,
dir,
true,
&mut pruned,
&mut warnings,
);
assert_eq!(pruned, vec!["stint-start/OLD.md".to_string()]);
assert!(
rec.companions.contains_key("AGENTS-EXECUTION-DAG.md"),
"still-bundled companion stays tracked"
);
assert!(
!rec.companions.contains_key("OLD.md"),
"dropped companion is removed from the record"
);
assert!(
!dir.join("OLD.md").exists(),
"orphan file removed on --force"
);
assert!(
dir.join("AGENTS-EXECUTION-DAG.md").exists(),
"bundled companion file left in place"
);
}
#[test]
fn reconcile_pi_companions_non_force_keeps_orphan_tracked() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
fs::write(dir.join("OLD.md"), b"old\n").unwrap();
let mut rec = PiSkillRecord {
sha256: sha256_hex(b"body"),
cli_version: CLI_VERSION.to_string(),
companions: BTreeMap::new(),
};
rec.companions
.insert("OLD.md".to_string(), sha256_hex(b"old\n"));
let mut pruned = Vec::new();
let mut warnings = Vec::new();
reconcile_pi_companions_at(
"stint-start",
&mut rec,
dir,
false,
&mut pruned,
&mut warnings,
);
assert!(pruned.is_empty(), "non-force prunes nothing");
assert!(
rec.companions.contains_key("OLD.md"),
"orphan stays tracked without --force"
);
assert!(dir.join("OLD.md").exists(), "orphan file left on disk");
}
#[test]
fn pi_provenance_v1_record_reads_and_upgrades_to_v2() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state").join("pi-installed-skills.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"schema_version":1,"skills":{"stint-start":{"sha256":"aa","cli_version":"0.1.0"}}}"#,
)
.unwrap();
let mut prov = load_pi_provenance_for_write(&path).unwrap();
assert_eq!(prov.schema_version, 1, "read preserves the on-disk version");
assert!(prov.skills["stint-start"].companions.is_empty());
prov.schema_version = PI_PROVENANCE_SCHEMA_VERSION;
write_pi_provenance(&path, &prov).unwrap();
let reread: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(reread["schema_version"], 2);
}
#[test]
fn prune_pi_mirror_preserves_dir_with_user_sibling() {
let root = tempfile::tempdir().unwrap();
let skill_dir = root.path().join("old-skill");
fs::create_dir_all(&skill_dir).unwrap();
let mirror = skill_dir.join("SKILL.md");
let body = b"our body\n";
fs::write(&mirror, body).unwrap();
fs::write(skill_dir.join("notes.md"), "mine").unwrap();
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(body),
&BTreeMap::new(),
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Removed));
assert!(!mirror.exists(), "our SKILL.md is removed");
assert!(skill_dir.exists(), "non-empty dir is preserved");
assert!(skill_dir.join("notes.md").exists(), "user sibling survives");
}
#[test]
fn prune_pi_mirror_refuses_diverged_copy() {
let root = tempfile::tempdir().unwrap();
let mirror = root.path().join("SKILL.md");
fs::write(&mirror, b"user has edited this").unwrap();
let mut warnings = Vec::new();
let outcome = prune_pi_mirror_at(
"old-skill",
&mirror,
Some(root.path()),
&sha256_hex(b"original body"),
&BTreeMap::new(),
&mut warnings,
);
assert!(matches!(outcome, PiPruneOutcome::Diverged));
assert!(
mirror.exists(),
"a diverged (user-owned) copy is NOT deleted"
);
assert!(warnings[0].starts_with("pi_mirror_diverged:"));
}
#[test]
fn prune_pi_mirror_drops_absent_symlink_and_dir() {
let root = tempfile::tempdir().unwrap();
let mut warnings = Vec::new();
let absent = root.path().join("gone/SKILL.md");
assert!(matches!(
prune_pi_mirror_at(
"gone",
&absent,
Some(root.path()),
"anyhash",
&BTreeMap::new(),
&mut warnings
),
PiPruneOutcome::Dropped
));
let squat = root.path().join("squat");
fs::create_dir_all(&squat).unwrap();
assert!(matches!(
prune_pi_mirror_at(
"squat",
&squat,
Some(root.path()),
"anyhash",
&BTreeMap::new(),
&mut warnings
),
PiPruneOutcome::Dropped
));
assert!(squat.is_dir(), "a squatting dir must be left intact");
let real = root.path().join("real.md");
fs::write(&real, b"body").unwrap();
let link = root.path().join("link-SKILL.md");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(matches!(
prune_pi_mirror_at(
"linked",
&link,
Some(root.path()),
&sha256_hex(b"body"),
&BTreeMap::new(),
&mut warnings
),
PiPruneOutcome::Dropped
));
assert!(link.exists(), "the symlink is left intact");
assert!(real.exists(), "the symlink target is untouched");
}
}