use std::borrow::Cow;
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use tempfile::NamedTempFile;
use crate::error::CliError;
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 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()
}
#[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 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),
});
}
}
"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),
});
}
}
_ => {}
}
let content = render_body_for_agent(agent_name, skill.body);
plan.push(PlanItem {
name: skill.name,
agent: agent_name,
path,
content,
});
}
if dest.is_none() && matches!(agent, AgentTarget::Claude | AgentTarget::All) {
plan.push(PlanItem {
name: skill.name,
agent: "pi",
path: default_path("pi", skill.name)?,
content: Cow::Borrowed(skill.body),
});
}
}
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());
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)?;
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 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>,
}
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"))
}
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> {
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
),
)
})?;
}
}
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 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
}
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> {
let Ok(content) = fs::read_to_string(marker_path) else {
return Vec::new();
};
content
.lines()
.filter_map(|line| line.trim().strip_prefix("companion:"))
.map(|rest| rest.trim().to_string())
.filter(|name| !name.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 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);
}
}