mod copy_tree;
mod install_decision;
mod install_plan;
mod ownership;
mod render;
pub mod stamp;
use anyhow::Context;
use provenance_macros::rule;
use render::frontmatter_field;
use serde::Serialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub const INSTALL_COMMAND: &str = "provenance skills install";
struct EmbeddedSkill {
directory: &'static str,
content: &'static str,
}
const EMBEDDED_SKILLS: &[EmbeddedSkill] = &[
EmbeddedSkill {
directory: "provenance-fork-tournament",
content: include_str!("../skills/provenance-fork-tournament/SKILL.md"),
},
EmbeddedSkill {
directory: "provenance-grounded-writing",
content: include_str!("../skills/provenance-grounded-writing/SKILL.md"),
},
EmbeddedSkill {
directory: "provenance-shaping",
content: include_str!("../skills/provenance-shaping/SKILL.md"),
},
EmbeddedSkill {
directory: "provenance-swarm-backtrace",
content: include_str!("../skills/provenance-swarm-backtrace/SKILL.md"),
},
];
#[derive(Serialize)]
pub struct SkillSummary {
name: String,
description: String,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FileStatus {
Unchanged,
Installed,
Updated,
Linked,
Removed,
}
#[derive(Serialize)]
pub struct InstallReport {
global: bool,
status: FileStatus,
canonical_dir: String,
claude_dir: String,
link_mode: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_reason: Option<String>,
files: Vec<FileInstallReport>,
}
#[derive(Serialize)]
pub struct SkillInstallStatus {
pub installed: bool,
pub install_command: &'static str,
pub missing_skills: Vec<String>,
}
#[derive(Serialize)]
struct FileInstallReport {
path: String,
status: FileStatus,
}
pub fn list() -> anyhow::Result<Vec<SkillSummary>> {
let mut summaries = EMBEDDED_SKILLS
.iter()
.map(|skill| {
Ok(SkillSummary {
name: skill_name(skill)?.to_string(),
description: frontmatter_field(skill.content, "description")
.context("embedded skill is missing description")?
.to_string(),
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
summaries.sort_by(|left, right| left.name.cmp(&right.name));
Ok(summaries)
}
pub fn show(name: &str) -> anyhow::Result<&'static str> {
for skill in EMBEDDED_SKILLS {
if skill_name(skill)? == name {
return Ok(skill.content);
}
}
anyhow::bail!("unknown skill: {name}")
}
pub fn install(global: bool, force: bool, copy: bool) -> anyhow::Result<InstallReport> {
let base = if global {
home_dir()?
} else {
std::env::current_dir()?
};
install_at(&base, global, force, copy)
}
pub fn install_status(repo: &Path) -> anyhow::Result<SkillInstallStatus> {
let mut missing_skills = Vec::new();
for skill in EMBEDDED_SKILLS {
if !canonical_skill_file(repo, skill).exists() {
missing_skills.push(skill_name(skill)?.to_string());
}
}
Ok(SkillInstallStatus {
installed: missing_skills.is_empty(),
install_command: INSTALL_COMMAND,
missing_skills,
})
}
pub fn render_status_markdown(status: &SkillInstallStatus) -> String {
let installed = if status.installed { "yes" } else { "no" };
format!(
"\n## Skills\n- Installed: {installed}\n- Install command: `{}` from the repo root\n",
status.install_command
)
}
pub fn install_at(
base: &Path,
global: bool,
force: bool,
copy: bool,
) -> anyhow::Result<InstallReport> {
install_plan::InstallPlan::build(
base,
install_plan::InstallRequest::Standalone {
global,
force,
copy,
},
)?
.apply()
}
pub struct InitSkillPlan(install_plan::InstallPlan);
pub fn plan_init_at(base: &Path) -> anyhow::Result<InitSkillPlan> {
Ok(InitSkillPlan(install_plan::InstallPlan::build(
base,
install_plan::InstallRequest::Init,
)?))
}
impl InitSkillPlan {
pub(crate) fn recheck(&self) -> anyhow::Result<()> {
self.0.recheck()
}
pub(crate) fn apply_in(
self,
rollback: &mut crate::atomic_file::FileRollbackJournal,
) -> anyhow::Result<()> {
self.0.apply_in(rollback).map(|_| ())
}
}
#[cfg(unix)]
fn create_dir_symlink(target: &Path, link_path: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link_path)
}
#[cfg(windows)]
fn create_dir_symlink(target: &Path, link_path: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link_path)
}
#[rule("rule_install_run_status")]
fn combined_status(files: &[FileInstallReport]) -> FileStatus {
if files
.iter()
.any(|file| matches!(file.status, FileStatus::Updated | FileStatus::Removed))
{
FileStatus::Updated
} else if files
.iter()
.any(|file| matches!(file.status, FileStatus::Installed | FileStatus::Linked))
{
FileStatus::Installed
} else {
FileStatus::Unchanged
}
}
fn file_report(path: &Path, status: FileStatus) -> FileInstallReport {
FileInstallReport {
path: path.display().to_string(),
status,
}
}
fn canonical_skill_file(repo: &Path, skill: &EmbeddedSkill) -> PathBuf {
repo.join(".agents/skills")
.join(skill.directory)
.join("SKILL.md")
}
fn relative_claude_target(name: &str) -> PathBuf {
PathBuf::from("..")
.join("..")
.join(".agents")
.join("skills")
.join(name)
}
fn skill_name(skill: &EmbeddedSkill) -> anyhow::Result<&'static str> {
let name =
frontmatter_field(skill.content, "name").context("embedded skill is missing name")?;
anyhow::ensure!(
name == skill.directory,
"embedded skill name {name} does not match directory {}",
skill.directory
);
Ok(name)
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn home_dir() -> anyhow::Result<PathBuf> {
home_dir_from_env(|key| std::env::var_os(key))
}
fn home_dir_from_env(mut var: impl FnMut(&str) -> Option<OsString>) -> anyhow::Result<PathBuf> {
if let Some(home) = var("HOME") {
return Ok(PathBuf::from(home));
}
if let Some(profile) = var("USERPROFILE") {
return Ok(PathBuf::from(profile));
}
if let (Some(mut drive), Some(path)) = (var("HOMEDRIVE"), var("HOMEPATH")) {
drive.push(path);
return Ok(PathBuf::from(drive));
}
anyhow::bail!("HOME or USERPROFILE is not set")
}
#[cfg(test)]
mod tests;