use anyhow::{Context, Result, anyhow};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use crate::fetch;
use crate::generate::{self, Client};
use crate::model::{Agent, Audience, Rule, Skill};
use crate::parse::frontmatter;
use crate::source::{GithubRepo, GitlabRepo, InstallSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ItemKind {
Rule,
Skill,
Agent,
}
#[derive(Debug, Clone)]
pub struct InstalledItem {
pub kind: ItemKind,
pub name: String,
pub client: Client,
pub output_path: PathBuf,
pub source_hash: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct InstallReport {
pub items: Vec<InstalledItem>,
pub bundles: Vec<crate::model::Bundle>,
}
const ALL_CLIENTS: [Client; 3] = [Client::Claude, Client::Copilot, Client::OpenCode];
pub fn install_from_local_path(source: &Path, target: &Path) -> Result<InstallReport> {
if is_bundle_file(source) {
return install_bundle_file(source, target);
}
let mut report = InstallReport::default();
install_skills(source, target, &mut report, None)?;
install_rules(source, target, &mut report, None)?;
install_agents(source, target, &mut report, None)?;
Ok(report)
}
fn install_bundle_file(bundle_path: &Path, target: &Path) -> Result<InstallReport> {
let registry_root = find_registry_root(bundle_path).with_context(|| {
format!(
"find SSOT registry root containing skills/, rules/, agents/, or bundles/ \
above {}",
bundle_path.display()
)
})?;
let entry = crate::parse::bundle::load(bundle_path)
.with_context(|| format!("load entry bundle {}", bundle_path.display()))?;
let available: Vec<crate::model::Bundle> = crate::parse::bundle::discover(®istry_root)
.with_context(|| {
format!(
"discover sibling bundles under registry root {}",
registry_root.display()
)
})?
.into_iter()
.map(|(_, b)| b)
.collect();
let resolved = crate::bundle::resolve(&entry, &available)?;
let mut report = InstallReport {
bundles: resolved.bundles.clone(),
..InstallReport::default()
};
install_skills(®istry_root, target, &mut report, Some(&resolved.items))?;
install_rules(®istry_root, target, &mut report, Some(&resolved.items))?;
install_agents(®istry_root, target, &mut report, Some(&resolved.items))?;
Ok(report)
}
fn is_bundle_file(path: &Path) -> bool {
path.is_file()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(crate::parse::bundle::BUNDLE_SUFFIX))
}
fn find_registry_root(bundle_path: &Path) -> Result<PathBuf> {
let parent = bundle_path
.parent()
.ok_or_else(|| anyhow!("bundle path {} has no parent", bundle_path.display()))?;
let mut cursor = parent;
loop {
if has_ssot_layout(cursor) {
return Ok(cursor.to_path_buf());
}
match cursor.parent() {
Some(p) => cursor = p,
None => break,
}
}
Ok(parent.to_path_buf())
}
fn has_ssot_layout(dir: &Path) -> bool {
["skills", "rules", "agents", "bundles"]
.iter()
.any(|child| dir.join(child).is_dir())
}
fn bundle_item_names(bundle: &crate::model::Bundle) -> Vec<String> {
let mut out = Vec::with_capacity(
bundle.items.rules.len() + bundle.items.skills.len() + bundle.items.agents.len(),
);
out.extend(bundle.items.rules.iter().cloned());
out.extend(bundle.items.skills.iter().cloned());
out.extend(bundle.items.agents.iter().cloned());
out
}
pub fn install_with_lockfile(source: &InstallSource, target: &Path) -> Result<InstallReport> {
let report = install_from_source(source, target)?;
let label = source.to_string();
let git_ref = match source {
InstallSource::Github(r) => r.git_ref.as_deref(),
InstallSource::Gitlab(r) => r.git_ref.as_deref(),
InstallSource::LocalPath(_) => None,
};
let hashes: std::collections::BTreeMap<(ItemKind, String), Option<String>> = report
.items
.iter()
.map(|it| ((it.kind, it.name.clone()), it.source_hash.clone()))
.collect();
let new_items = crate::lockfile::items_from_report(&report, &label, git_ref, |k, n| {
hashes.get(&(k, n.to_string())).cloned().flatten()
});
let mut lock = crate::lockfile::Lockfile::load(target)?;
for item in new_items {
lock.upsert(item);
}
for bundle in &report.bundles {
lock.upsert_bundle(crate::lockfile::LockedBundle {
name: bundle.name.clone(),
source: label.clone(),
git_ref: git_ref.map(str::to_string),
items: bundle_item_names(bundle),
});
}
lock.save(target)?;
crate::ancillary::ensure_claude_bridge(target)?;
let has_rules = report.items.iter().any(|i| i.kind == ItemKind::Rule);
crate::ancillary::ensure_opencode_rules_registered(target, has_rules)?;
crate::ancillary::ensure_vscode_instructions_registered(target, has_rules)?;
Ok(report)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveFilter {
ByNames(Vec<String>),
BySource(String),
}
#[derive(Debug, Default, Clone)]
pub struct RemoveReport {
pub items: Vec<RemovedItem>,
}
#[derive(Debug, Clone)]
pub struct RemovedItem {
pub kind: ItemKind,
pub name: String,
pub deleted_files: Vec<PathBuf>,
}
pub fn remove(target: &Path, filter: RemoveFilter) -> Result<RemoveReport> {
let mut lock = crate::lockfile::Lockfile::load(target)?;
let to_remove: Vec<crate::lockfile::LockedItem> = match &filter {
RemoveFilter::ByNames(names) => lock
.items
.iter()
.filter(|i| names.iter().any(|n| n == &i.name))
.cloned()
.collect(),
RemoveFilter::BySource(source) => lock
.items
.iter()
.filter(|i| &i.source == source)
.cloned()
.collect(),
};
if let RemoveFilter::ByNames(names) = &filter {
let matched: std::collections::BTreeSet<&str> =
to_remove.iter().map(|i| i.name.as_str()).collect();
let unknown: Vec<&str> = names
.iter()
.filter(|n| !matched.contains(n.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
anyhow::bail!("not in lockfile: {}", unknown.join(", "));
}
}
let mut report = RemoveReport::default();
for entry in &to_remove {
let kind = parse_kind(&entry.kind)
.with_context(|| format!("lockfile entry {}: unknown kind", entry.name))?;
let mut deleted_files = Vec::new();
for client in ALL_CLIENTS {
let rel = output_path(kind, client, &entry.name);
let full = target.join(&rel);
if full.exists() {
fs::remove_file(&full).with_context(|| format!("delete {}", full.display()))?;
deleted_files.push(rel);
if let Some(parent) = full.parent() {
let _ = fs::remove_dir(parent);
}
}
}
lock.remove(&entry.kind, &entry.name);
report.items.push(RemovedItem {
kind,
name: entry.name.clone(),
deleted_files,
});
}
lock.save(target)?;
Ok(report)
}
fn parse_kind(s: &str) -> Result<ItemKind> {
match s {
"skill" => Ok(ItemKind::Skill),
"rule" => Ok(ItemKind::Rule),
"agent" => Ok(ItemKind::Agent),
other => anyhow::bail!("unknown kind `{other}`"),
}
}
fn kind_subdir(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Skill => "skills",
ItemKind::Rule => "rules",
ItemKind::Agent => "agents",
}
}
#[derive(Debug, Clone)]
pub struct MissingOutput {
pub kind: ItemKind,
pub name: String,
pub missing_files: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct StaleHash {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub stored_hash: Option<String>,
pub current_hash: Option<String>,
}
#[derive(Debug, Clone)]
pub struct OrphanEntry {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub reason: OrphanReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrphanReason {
LocalPathGone,
ItemMissingInSource,
}
#[derive(Debug, Default, Clone)]
pub struct DoctorReport {
pub missing_outputs: Vec<MissingOutput>,
pub stale_hashes: Vec<StaleHash>,
pub orphan_entries: Vec<OrphanEntry>,
}
impl DoctorReport {
pub fn is_clean(&self) -> bool {
self.missing_outputs.is_empty()
&& self.stale_hashes.is_empty()
&& self.orphan_entries.is_empty()
}
}
pub fn doctor(target: &Path) -> Result<DoctorReport> {
let lock = crate::lockfile::Lockfile::load(target)?;
let mut report = DoctorReport::default();
for entry in &lock.items {
let kind = parse_kind(&entry.kind).with_context(|| {
format!(
"lockfile entry {}: unknown kind `{}`",
entry.name, entry.kind
)
})?;
let mut missing = Vec::new();
for client in ALL_CLIENTS {
let rel = output_path(kind, client, &entry.name);
if !target.join(&rel).exists() {
missing.push(rel);
}
}
if !missing.is_empty() {
report.missing_outputs.push(MissingOutput {
kind,
name: entry.name.clone(),
missing_files: missing,
});
}
if let Some(local_path) = entry.source.strip_prefix("local:") {
let ssot_root = Path::new(local_path);
if !ssot_root.is_dir() {
report.orphan_entries.push(OrphanEntry {
kind,
name: entry.name.clone(),
source: entry.source.clone(),
reason: OrphanReason::LocalPathGone,
});
continue;
}
let item_dir = ssot_root.join(kind_subdir(kind)).join(&entry.name);
if !item_dir.is_dir() {
report.orphan_entries.push(OrphanEntry {
kind,
name: entry.name.clone(),
source: entry.source.clone(),
reason: OrphanReason::ItemMissingInSource,
});
continue;
}
let current = hash_item_dir(&item_dir);
if current != entry.hash {
report.stale_hashes.push(StaleHash {
kind,
name: entry.name.clone(),
source: entry.source.clone(),
stored_hash: entry.hash.clone(),
current_hash: current,
});
}
}
}
Ok(report)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateMode {
Apply,
DryRun,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateStatus {
UpToDate,
Updated {
old_hash: Option<String>,
new_hash: Option<String>,
},
WouldChange {
old_hash: Option<String>,
new_hash: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct UpdatedItem {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub status: UpdateStatus,
}
#[derive(Debug, Default, Clone)]
pub struct UpdateReport {
pub items: Vec<UpdatedItem>,
}
pub fn update(target: &Path, names: &[String], mode: UpdateMode) -> Result<UpdateReport> {
let lock = crate::lockfile::Lockfile::load(target)?;
let entries: Vec<crate::lockfile::LockedItem> = if names.is_empty() {
lock.items.clone()
} else {
let matched: Vec<crate::lockfile::LockedItem> = lock
.items
.iter()
.filter(|i| names.iter().any(|n| n == &i.name))
.cloned()
.collect();
let matched_names: std::collections::BTreeSet<&str> =
matched.iter().map(|i| i.name.as_str()).collect();
let unknown: Vec<&str> = names
.iter()
.filter(|n| !matched_names.contains(n.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
anyhow::bail!("not in lockfile: {}", unknown.join(", "));
}
matched
};
let mut by_source: std::collections::BTreeMap<String, Vec<crate::lockfile::LockedItem>> =
std::collections::BTreeMap::new();
for entry in entries {
by_source
.entry(entry.source.clone())
.or_default()
.push(entry);
}
let mut report = UpdateReport::default();
for (source_label, source_entries) in by_source {
let source = crate::source::parse_install_source_label(&source_label)
.with_context(|| format!("parse lockfile source label `{source_label}`"))?;
match mode {
UpdateMode::Apply => {
let install_report = install_with_lockfile(&source, target)?;
let mut new_hashes: std::collections::BTreeMap<(ItemKind, String), Option<String>> =
std::collections::BTreeMap::new();
for it in &install_report.items {
new_hashes.insert((it.kind, it.name.clone()), it.source_hash.clone());
}
for entry in &source_entries {
let kind = parse_kind(&entry.kind)?;
let new_hash = new_hashes
.get(&(kind, entry.name.clone()))
.cloned()
.flatten();
let status = if new_hash == entry.hash {
UpdateStatus::UpToDate
} else {
UpdateStatus::Updated {
old_hash: entry.hash.clone(),
new_hash,
}
};
report.items.push(UpdatedItem {
kind,
name: entry.name.clone(),
source: source_label.clone(),
status,
});
}
}
UpdateMode::DryRun => {
let (root, _guard) = fetch_ssot(&source)?;
let new_hashes = hash_source_items(&root);
for entry in &source_entries {
let kind = parse_kind(&entry.kind)?;
let new_hash = new_hashes
.get(&(kind, entry.name.clone()))
.cloned()
.flatten();
let status = if new_hash == entry.hash {
UpdateStatus::UpToDate
} else {
UpdateStatus::WouldChange {
old_hash: entry.hash.clone(),
new_hash,
}
};
report.items.push(UpdatedItem {
kind,
name: entry.name.clone(),
source: source_label.clone(),
status,
});
}
}
}
}
Ok(report)
}
fn hash_source_items(
source_root: &Path,
) -> std::collections::BTreeMap<(ItemKind, String), Option<String>> {
let mut out = std::collections::BTreeMap::new();
for kind in [ItemKind::Skill, ItemKind::Rule, ItemKind::Agent] {
let kind_dir = source_root.join(kind_subdir(kind));
let Ok(entries) = fs::read_dir(&kind_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
out.insert((kind, name.to_string()), hash_item_dir(&path));
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct ListedItem {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub git_ref: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ListedBundle {
pub name: String,
pub source: String,
pub git_ref: Option<String>,
pub items: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ListReport {
pub rules: Vec<ListedItem>,
pub skills: Vec<ListedItem>,
pub agents: Vec<ListedItem>,
pub bundles: Vec<ListedBundle>,
}
impl ListReport {
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
&& self.skills.is_empty()
&& self.agents.is_empty()
&& self.bundles.is_empty()
}
}
pub fn list(target: &Path) -> Result<ListReport> {
let lock = crate::lockfile::Lockfile::load(target)?;
let mut report = ListReport::default();
for entry in &lock.items {
let kind = parse_kind(&entry.kind).with_context(|| {
format!(
"lockfile entry {}: unknown kind `{}`",
entry.name, entry.kind
)
})?;
let listed = ListedItem {
kind,
name: entry.name.clone(),
source: entry.source.clone(),
git_ref: entry.git_ref.clone(),
};
match kind {
ItemKind::Rule => report.rules.push(listed),
ItemKind::Skill => report.skills.push(listed),
ItemKind::Agent => report.agents.push(listed),
}
}
for bucket in [&mut report.rules, &mut report.skills, &mut report.agents] {
bucket.sort_by(|a, b| a.name.cmp(&b.name));
}
for bundle in &lock.bundles {
report.bundles.push(ListedBundle {
name: bundle.name.clone(),
source: bundle.source.clone(),
git_ref: bundle.git_ref.clone(),
items: bundle.items.clone(),
});
}
report.bundles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(report)
}
pub fn install_from_source(source: &InstallSource, target: &Path) -> Result<InstallReport> {
match source {
InstallSource::LocalPath(path) => install_from_local_path(path, target),
InstallSource::Github(repo) => install_from_github(repo, target),
InstallSource::Gitlab(repo) => install_from_gitlab(repo, target),
}
}
fn install_from_github(repo: &GithubRepo, target: &Path) -> Result<InstallReport> {
install_from_git_url(
&github_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
target,
)
}
fn install_from_gitlab(repo: &GitlabRepo, target: &Path) -> Result<InstallReport> {
install_from_git_url(
&gitlab_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
target,
)
}
fn github_clone_url(repo: &GithubRepo) -> String {
format!("https://github.com/{}/{}.git", repo.owner, repo.name)
}
fn gitlab_clone_url(repo: &GitlabRepo) -> String {
format!("https://{}/{}/{}.git", repo.host, repo.owner, repo.name)
}
fn github_authenticated_url(repo: &GithubRepo) -> Result<String> {
Ok(match crate::auth::resolve_github_token().token() {
Some(token) => inject_basic_auth(&github_clone_url(repo), "x-access-token", token)?,
None => github_clone_url(repo),
})
}
fn gitlab_authenticated_url(repo: &GitlabRepo) -> Result<String> {
Ok(match crate::auth::resolve_gitlab_token().token() {
Some(token) => inject_basic_auth(&gitlab_clone_url(repo), "oauth2", token)?,
None => gitlab_clone_url(repo),
})
}
pub fn fetch_ssot(source: &InstallSource) -> Result<(PathBuf, Option<tempfile::TempDir>)> {
match source {
InstallSource::LocalPath(p) => Ok((p.clone(), None)),
InstallSource::Github(repo) => clone_to_tempdir(
&github_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
),
InstallSource::Gitlab(repo) => clone_to_tempdir(
&gitlab_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
),
}
}
fn clone_to_tempdir(
url: &str,
git_ref: Option<&str>,
subfolder: Option<&str>,
owner: &str,
name: &str,
) -> Result<(PathBuf, Option<tempfile::TempDir>)> {
let tmp = tempfile::tempdir().context("create temp dir for clone")?;
fetch::shallow_clone(url, git_ref, "clone", tmp.path())
.map_err(|e| anyhow!("git clone {}: {}", url, e))?;
let source = fetch::resolve_subfolder(&tmp.path().join("clone"), subfolder, owner, name)
.map_err(|e| anyhow!("{}", e))?;
Ok((source, Some(tmp)))
}
fn inject_basic_auth(url: &str, user: &str, token: &str) -> Result<String> {
if token.is_empty() {
anyhow::bail!("refusing to inject empty token into URL");
}
let rest = url
.strip_prefix("https://")
.ok_or_else(|| anyhow!("expected https:// URL for token injection, got: {url}"))?;
Ok(format!(
"https://{}:{}@{}",
percent_encode_userinfo(user),
percent_encode_userinfo(token),
rest
))
}
fn percent_encode_userinfo(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
out
}
pub fn install_from_git_url(
url: &str,
git_ref: Option<&str>,
subfolder: Option<&str>,
owner: &str,
name: &str,
target: &Path,
) -> Result<InstallReport> {
let tmp = tempfile::tempdir().context("create temp dir for clone")?;
fetch::shallow_clone(url, git_ref, "clone", tmp.path())
.map_err(|e| anyhow!("git clone {}: {}", url, e))?;
let source = fetch::resolve_subfolder(&tmp.path().join("clone"), subfolder, owner, name)
.map_err(|e| anyhow!("{}", e))?;
install_from_local_path(&source, target)
}
fn install_skills(
source: &Path,
target: &Path,
report: &mut InstallReport,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<()> {
for (name, dir) in iter_item_dirs(&source.join("skills"))? {
if let Some(items) = filter
&& !items.contains(ItemKind::Skill, &name)
{
continue;
}
let entry_path = dir.join("SKILL.md");
if !entry_path.exists() {
continue;
}
let raw = fs::read_to_string(&entry_path)
.with_context(|| format!("read {}", entry_path.display()))?;
let (skill, body) = frontmatter::parse::<Skill>(&raw)
.with_context(|| format!("parse {}", entry_path.display()))?;
let audience = skill.audience.as_deref();
let source_hash = hash_item_dir(&dir);
for client in ALL_CLIENTS {
if !targets(client, audience) {
continue;
}
let rendered = generate::render_skill(&skill, body, client)
.with_context(|| format!("render skill {} for {:?}", name, client))?;
let rel = skill_output_path(client, &name);
write_output(target, &rel, &rendered)?;
report.items.push(InstalledItem {
kind: ItemKind::Skill,
name: name.clone(),
client,
output_path: rel,
source_hash: source_hash.clone(),
});
}
}
Ok(())
}
fn install_rules(
source: &Path,
target: &Path,
report: &mut InstallReport,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<()> {
for (name, dir) in iter_item_dirs(&source.join("rules"))? {
if let Some(items) = filter
&& !items.contains(ItemKind::Rule, &name)
{
continue;
}
let entry_path = dir.join("RULE.md");
if !entry_path.exists() {
continue;
}
let raw = fs::read_to_string(&entry_path)
.with_context(|| format!("read {}", entry_path.display()))?;
let (rule, body) = frontmatter::parse::<Rule>(&raw)
.with_context(|| format!("parse {}", entry_path.display()))?;
let audience = rule.audience.as_deref();
let source_hash = hash_item_dir(&dir);
for client in ALL_CLIENTS {
if !targets(client, audience) {
continue;
}
let rendered = generate::render_rule(&rule, body, client)
.with_context(|| format!("render rule {} for {:?}", name, client))?;
let rel = rule_output_path(client, &name);
write_output(target, &rel, &rendered)?;
report.items.push(InstalledItem {
kind: ItemKind::Rule,
name: name.clone(),
client,
output_path: rel,
source_hash: source_hash.clone(),
});
}
}
Ok(())
}
fn install_agents(
source: &Path,
target: &Path,
report: &mut InstallReport,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<()> {
for (name, dir) in iter_item_dirs(&source.join("agents"))? {
if let Some(items) = filter
&& !items.contains(ItemKind::Agent, &name)
{
continue;
}
let entry_path = dir.join("AGENT.md");
if !entry_path.exists() {
continue;
}
let raw = fs::read_to_string(&entry_path)
.with_context(|| format!("read {}", entry_path.display()))?;
let (agent, body) = frontmatter::parse::<Agent>(&raw)
.with_context(|| format!("parse {}", entry_path.display()))?;
let audience = agent.audience.as_deref();
let source_hash = hash_item_dir(&dir);
for client in ALL_CLIENTS {
if !targets(client, audience) {
continue;
}
let rendered = generate::render_agent(&agent, body, client)
.with_context(|| format!("render agent {} for {:?}", name, client))?;
let rel = agent_output_path(client, &name);
write_output(target, &rel, &rendered)?;
report.items.push(InstalledItem {
kind: ItemKind::Agent,
name: name.clone(),
client,
output_path: rel,
source_hash: source_hash.clone(),
});
}
}
Ok(())
}
pub(crate) fn output_path(kind: ItemKind, client: Client, name: &str) -> PathBuf {
match kind {
ItemKind::Skill => skill_output_path(client, name),
ItemKind::Rule => rule_output_path(client, name),
ItemKind::Agent => agent_output_path(client, name),
}
}
fn skill_output_path(client: Client, name: &str) -> PathBuf {
match client {
Client::Claude => PathBuf::from(format!(".claude/skills/{name}/SKILL.md")),
Client::Copilot => PathBuf::from(format!(".github/skills/{name}/SKILL.md")),
Client::OpenCode => PathBuf::from(format!(".agents/skills/{name}/SKILL.md")),
}
}
fn rule_output_path(client: Client, name: &str) -> PathBuf {
match client {
Client::Claude => PathBuf::from(format!(".claude/rules/{name}.md")),
Client::Copilot => PathBuf::from(format!(".github/instructions/{name}.instructions.md")),
Client::OpenCode => PathBuf::from(format!(".agents/rules/{name}/RULE.md")),
}
}
fn agent_output_path(client: Client, name: &str) -> PathBuf {
match client {
Client::Claude => PathBuf::from(format!(".claude/agents/{name}.md")),
Client::Copilot => PathBuf::from(format!(".github/agents/{name}.agent.md")),
Client::OpenCode => PathBuf::from(format!(".opencode/agents/{name}.md")),
}
}
fn write_output(target: &Path, rel: &Path, content: &str) -> Result<()> {
let full = target.join(rel);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).with_context(|| format!("create dir {}", parent.display()))?;
}
fs::write(&full, content).with_context(|| format!("write {}", full.display()))?;
Ok(())
}
pub(crate) fn hash_item_dir(dir: &Path) -> Option<String> {
let mut files = Vec::new();
collect_files(dir, &mut files);
if files.is_empty() {
return None;
}
files.sort();
let mut hasher = Sha256::new();
for file in &files {
let relative = file.strip_prefix(dir).unwrap_or(file);
hasher.update(relative.to_string_lossy().as_bytes());
if let Ok(content) = fs::read(file) {
hasher.update(&content);
}
}
Some(
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
)
}
fn collect_files(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(&path, files);
} else {
files.push(path);
}
}
}
fn iter_item_dirs(kind_root: &Path) -> Result<Vec<(String, PathBuf)>> {
if !kind_root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in
fs::read_dir(kind_root).with_context(|| format!("read_dir {}", kind_root.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry
.file_name()
.to_str()
.map(str::to_owned)
.with_context(|| format!("non-UTF8 name in {}", kind_root.display()))?;
out.push((name, path));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
fn targets(client: Client, audience: Option<&[Audience]>) -> bool {
match audience {
None => true,
Some(list) => list.iter().any(|a| audience_matches(client, *a)),
}
}
fn audience_matches(client: Client, a: Audience) -> bool {
matches!(
(client, a),
(Client::Claude, Audience::Claude)
| (Client::Copilot, Audience::Copilot)
| (Client::OpenCode, Audience::OpenCode)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_paths_match_spec() {
assert_eq!(
skill_output_path(Client::Claude, "x"),
PathBuf::from(".claude/skills/x/SKILL.md")
);
assert_eq!(
skill_output_path(Client::OpenCode, "x"),
PathBuf::from(".agents/skills/x/SKILL.md")
);
assert_eq!(
rule_output_path(Client::Copilot, "x"),
PathBuf::from(".github/instructions/x.instructions.md")
);
assert_eq!(
rule_output_path(Client::OpenCode, "x"),
PathBuf::from(".agents/rules/x/RULE.md")
);
assert_eq!(
agent_output_path(Client::Copilot, "x"),
PathBuf::from(".github/agents/x.agent.md")
);
assert_eq!(
agent_output_path(Client::OpenCode, "x"),
PathBuf::from(".opencode/agents/x.md")
);
}
#[test]
fn audience_none_targets_all_clients() {
for c in ALL_CLIENTS {
assert!(targets(c, None));
}
}
#[test]
fn audience_subset_filters_other_clients() {
let only_claude = vec![Audience::Claude];
assert!(targets(Client::Claude, Some(&only_claude)));
assert!(!targets(Client::Copilot, Some(&only_claude)));
assert!(!targets(Client::OpenCode, Some(&only_claude)));
}
#[test]
fn github_clone_url_is_https_dot_git() {
let repo = GithubRepo {
owner: "driftsys".into(),
name: "skills".into(),
git_ref: None,
subfolder: None,
};
assert_eq!(
github_clone_url(&repo),
"https://github.com/driftsys/skills.git"
);
}
#[test]
fn gitlab_clone_url_uses_repo_host() {
let repo = GitlabRepo {
host: "gitlab.com".into(),
owner: "driftsys".into(),
name: "skills".into(),
git_ref: None,
subfolder: None,
};
assert_eq!(
gitlab_clone_url(&repo),
"https://gitlab.com/driftsys/skills.git"
);
let self_hosted = GitlabRepo {
host: "gitlab.example.com".into(),
owner: "team".into(),
name: "rules".into(),
git_ref: None,
subfolder: None,
};
assert_eq!(
gitlab_clone_url(&self_hosted),
"https://gitlab.example.com/team/rules.git"
);
}
#[test]
fn inject_basic_auth_github_oauth_user() {
let url = inject_basic_auth(
"https://github.com/driftsys/skills.git",
"x-access-token",
"ghp_AbCdEf1234567890",
)
.expect("inject");
assert_eq!(
url,
"https://x-access-token:ghp_AbCdEf1234567890@github.com/driftsys/skills.git"
);
}
#[test]
fn inject_basic_auth_gitlab_oauth_user() {
let url = inject_basic_auth(
"https://gitlab.example.com/team/rules.git",
"oauth2",
"glpat-XYZ_abc-123",
)
.expect("inject");
assert_eq!(
url,
"https://oauth2:glpat-XYZ_abc-123@gitlab.example.com/team/rules.git"
);
}
#[test]
fn inject_basic_auth_percent_encodes_special_chars() {
let url = inject_basic_auth(
"https://gitlab.com/o/r.git",
"oauth2",
"tok:en@with/special%chars",
)
.expect("inject");
assert_eq!(
url,
"https://oauth2:tok%3Aen%40with%2Fspecial%25chars@gitlab.com/o/r.git"
);
}
#[test]
fn inject_basic_auth_rejects_empty_token() {
let err = inject_basic_auth("https://github.com/o/r.git", "x-access-token", "")
.expect_err("must reject");
assert!(err.to_string().contains("empty token"));
}
#[test]
fn inject_basic_auth_rejects_non_https() {
let err = inject_basic_auth("http://github.com/o/r.git", "x-access-token", "tok")
.expect_err("must reject");
assert!(err.to_string().contains("https://"));
let err = inject_basic_auth("git@github.com:o/r.git", "x-access-token", "tok")
.expect_err("must reject ssh form");
assert!(err.to_string().contains("https://"));
}
#[test]
fn percent_encode_userinfo_passes_unreserved_unchanged() {
assert_eq!(
percent_encode_userinfo("Abc-_.~123"),
"Abc-_.~123",
"unreserved chars unchanged"
);
}
#[test]
fn percent_encode_userinfo_escapes_userinfo_separators() {
assert_eq!(percent_encode_userinfo(":"), "%3A");
assert_eq!(percent_encode_userinfo("@"), "%40");
assert_eq!(percent_encode_userinfo("/"), "%2F");
assert_eq!(percent_encode_userinfo("%"), "%25");
}
#[test]
fn parse_kind_round_trips_lockfile_strings() {
for k in [ItemKind::Rule, ItemKind::Skill, ItemKind::Agent] {
let label = match k {
ItemKind::Rule => "rule",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
};
assert_eq!(parse_kind(label).unwrap(), k);
}
}
#[test]
fn parse_kind_rejects_unknown_string() {
let err = parse_kind("bundle").expect_err("must reject");
assert!(err.to_string().contains("bundle"));
}
#[test]
fn doctor_report_is_clean_when_all_buckets_empty() {
let report = DoctorReport::default();
assert!(report.is_clean());
}
#[test]
fn doctor_report_not_clean_with_any_drift() {
let mut report = DoctorReport::default();
report.missing_outputs.push(MissingOutput {
kind: ItemKind::Skill,
name: "x".into(),
missing_files: vec![PathBuf::from("a")],
});
assert!(!report.is_clean());
let mut report = DoctorReport::default();
report.stale_hashes.push(StaleHash {
kind: ItemKind::Skill,
name: "x".into(),
source: "local:/p".into(),
stored_hash: None,
current_hash: Some("abc".into()),
});
assert!(!report.is_clean());
let mut report = DoctorReport::default();
report.orphan_entries.push(OrphanEntry {
kind: ItemKind::Skill,
name: "x".into(),
source: "local:/p".into(),
reason: OrphanReason::LocalPathGone,
});
assert!(!report.is_clean());
}
#[test]
fn kind_subdir_matches_install_pipeline_layout() {
assert_eq!(kind_subdir(ItemKind::Skill), "skills");
assert_eq!(kind_subdir(ItemKind::Rule), "rules");
assert_eq!(kind_subdir(ItemKind::Agent), "agents");
}
#[test]
fn output_path_dispatches_to_per_kind_helper() {
for client in ALL_CLIENTS {
assert_eq!(
output_path(ItemKind::Skill, client, "x"),
skill_output_path(client, "x")
);
assert_eq!(
output_path(ItemKind::Rule, client, "x"),
rule_output_path(client, "x")
);
assert_eq!(
output_path(ItemKind::Agent, client, "x"),
agent_output_path(client, "x")
);
}
}
}