use anyhow::{Context, Result, anyhow};
use serde::Serialize;
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, Serialize)]
#[serde(rename_all = "lowercase")]
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>,
pub plugin_results: Vec<PluginResult>,
}
#[derive(Debug, Clone)]
pub struct PluginResult {
pub name: String,
pub client: String,
pub outcome: crate::plugin::PluginOutcome,
pub identifier: String,
pub bundle: String,
pub install_url: Option<String>,
}
const ALL_CLIENTS: [Client; 3] = [Client::Claude, Client::Copilot, Client::OpenCode];
pub fn install_from_local_path(
source: &Path,
target: &Path,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<InstallReport> {
if is_bundle_file(source) {
return install_bundle_file(source, target);
}
let mut report = InstallReport::default();
install_skills(source, target, &mut report, filter)?;
install_rules(source, target, &mut report, filter)?;
install_agents(source, target, &mut report, filter)?;
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_bundle_by_name(root: &Path, name: &str) -> Option<PathBuf> {
let target_filename = format!("{}{}", name, crate::parse::bundle::BUNDLE_SUFFIX);
find_bundle_recursive(root, &target_filename)
}
fn find_bundle_recursive(dir: &Path, target: &str) -> Option<PathBuf> {
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let entry_name = entry.file_name();
let name_str = entry_name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_file() && name_str == target {
return Some(path);
}
if path.is_dir()
&& let Some(found) = find_bundle_recursive(&path, target)
{
return Some(found);
}
}
None
}
fn has_matching_items(source: &Path, name: &str) -> bool {
let item_dir = source.join(name);
if !item_dir.is_dir() {
return false;
}
item_dir.join("SKILL.md").is_file()
|| item_dir.join("RULE.md").is_file()
|| item_dir.join("AGENT.md").is_file()
}
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 {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if is_item_dir(&path) {
return true;
}
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_dir() && is_item_dir(&sub_path) {
return true;
}
}
}
}
false
}
fn is_item_dir(path: &Path) -> bool {
path.join("RULE.md").is_file()
|| path.join("SKILL.md").is_file()
|| path.join("AGENT.md").is_file()
}
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,
items: &[String],
plugin_scope: crate::plugin::PluginScope,
) -> Result<InstallReport> {
let mut report = if items.is_empty() {
install_from_source(source, target, None)?
} else {
install_with_name_resolution(source, target, items)?
};
let plugin_results = install_plugins_from_bundles(&report.bundles, plugin_scope);
report.plugin_results = plugin_results;
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),
});
}
for pr in &report.plugin_results {
use crate::lockfile::PluginInstallStatus;
use crate::plugin::PluginOutcome;
let status = match &pr.outcome {
PluginOutcome::Success => PluginInstallStatus::Installed,
PluginOutcome::CliNotFound => PluginInstallStatus::Skipped,
PluginOutcome::Failed { .. } => continue,
};
lock.upsert_plugin(crate::lockfile::LockedPlugin {
name: pr.name.clone(),
client: pr.client.clone(),
identifier: pr.identifier.clone(),
scope: match plugin_scope {
crate::plugin::PluginScope::Project => Some("project".into()),
crate::plugin::PluginScope::User => Some("user".into()),
},
bundle: pr.bundle.clone(),
status,
});
}
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)
}
fn install_with_name_resolution(
source: &InstallSource,
target: &Path,
names: &[String],
) -> Result<InstallReport> {
let (local_source, _tmp) = fetch_ssot(source)?;
let mut bundle_paths: Vec<PathBuf> = Vec::new();
let mut item_names: Vec<String> = Vec::new();
for name in names {
let has_items = has_matching_items(&local_source, name);
let bundle_path = find_bundle_by_name(&local_source, name);
match (has_items, bundle_path) {
(true, Some(bp)) => {
let rel = bp
.strip_prefix(&local_source)
.unwrap_or(&bp)
.display()
.to_string();
anyhow::bail!(
"'{}' matches both an item and a bundle\n\n \
item: {}/{}\n \
bundle: {}\n\n\
Disambiguate by using the full path to the bundle:\n \
upskill add <source>:{}",
name,
name,
detect_item_entrypoint(&local_source, name),
rel,
rel,
);
}
(false, Some(bp)) => bundle_paths.push(bp),
(true, None) => item_names.push(name.clone()),
(false, None) => {
anyhow::bail!("no matching items or bundles in source for: {}", name);
}
}
}
let mut report = InstallReport::default();
for bp in &bundle_paths {
let bundle_report = install_bundle_file(bp, target)?;
report.items.extend(bundle_report.items);
report.bundles.extend(bundle_report.bundles);
}
if !item_names.is_empty() {
let filter = crate::bundle::ResolvedItems {
rules: item_names.clone(),
skills: item_names.clone(),
agents: item_names.clone(),
};
let item_report = install_from_local_path(&local_source, target, Some(&filter))?;
report.items.extend(item_report.items);
}
Ok(report)
}
fn detect_item_entrypoint(source: &Path, name: &str) -> &'static str {
let dir = source.join(name);
if dir.join("SKILL.md").is_file() {
"SKILL.md"
} else if dir.join("RULE.md").is_file() {
"RULE.md"
} else if dir.join("AGENT.md").is_file() {
"AGENT.md"
} else {
"SKILL.md" }
}
fn install_plugins_from_bundles(
bundles: &[crate::model::Bundle],
scope: crate::plugin::PluginScope,
) -> Vec<PluginResult> {
let mut results = Vec::new();
for bundle in bundles {
for (plugin_name, entry) in &bundle.plugins {
if let Some(claude) = &entry.claude {
let outcome = crate::plugin::install_claude_plugin(claude, scope);
let identifier = format!("{}@{}", claude.plugin, claude.source);
results.push(PluginResult {
name: plugin_name.clone(),
client: "claude".into(),
outcome,
identifier,
bundle: bundle.name.clone(),
install_url: claude.install_url.clone(),
});
}
if let Some(vscode) = &entry.vscode {
let outcome = crate::plugin::install_vscode_extension(vscode);
results.push(PluginResult {
name: plugin_name.clone(),
client: "vscode".into(),
outcome,
identifier: vscode.extension.clone(),
bundle: bundle.name.clone(),
install_url: vscode.install_url.clone(),
});
}
if let Some(opencode) = &entry.opencode {
let outcome = crate::plugin::install_opencode_plugin(opencode);
results.push(PluginResult {
name: plugin_name.clone(),
client: "opencode".into(),
outcome,
identifier: opencode.module.clone(),
bundle: bundle.name.clone(),
install_url: opencode.install_url.clone(),
});
}
if let Some(copilot) = &entry.copilot {
let outcome = crate::plugin::install_copilot_plugin(copilot);
let identifier = format!("{}@{}", copilot.plugin, copilot.source);
results.push(PluginResult {
name: plugin_name.clone(),
client: "copilot".into(),
outcome,
identifier,
bundle: bundle.name.clone(),
install_url: copilot.install_url.clone(),
});
}
}
}
results
}
#[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}`"),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MissingOutput {
pub kind: ItemKind,
pub name: String,
pub missing_files: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
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, Serialize)]
pub struct OrphanEntry {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub reason: OrphanReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OrphanReason {
LocalPathGone,
ItemMissingInSource,
}
#[derive(Debug, Clone, Serialize)]
pub struct MissingPlugin {
pub name: String,
pub client: String,
pub identifier: String,
pub bundle: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SkippedPlugin {
pub name: String,
pub client: String,
pub identifier: String,
pub bundle: String,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct DoctorReport {
pub missing_outputs: Vec<MissingOutput>,
pub stale_hashes: Vec<StaleHash>,
pub orphan_entries: Vec<OrphanEntry>,
pub missing_plugins: Vec<MissingPlugin>,
pub skipped_plugins: Vec<SkippedPlugin>,
}
impl DoctorReport {
pub fn is_clean(&self) -> bool {
self.missing_outputs.is_empty()
&& self.stale_hashes.is_empty()
&& self.orphan_entries.is_empty()
&& self.missing_plugins.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(&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,
});
}
}
}
for plugin in &lock.plugins {
use crate::lockfile::PluginInstallStatus;
use crate::plugin::{
PluginScope, check_claude_plugin_installed, check_opencode_plugin_installed,
check_vscode_extension_installed,
};
match &plugin.status {
PluginInstallStatus::Skipped => {
report.skipped_plugins.push(SkippedPlugin {
name: plugin.name.clone(),
client: plugin.client.clone(),
identifier: plugin.identifier.clone(),
bundle: plugin.bundle.clone(),
});
}
PluginInstallStatus::Installed => {
let check = match plugin.client.as_str() {
"claude" => {
let scope = match plugin.scope.as_deref() {
Some("user") => PluginScope::User,
_ => PluginScope::Project,
};
check_claude_plugin_installed(&plugin.name, scope)
}
"vscode" => check_vscode_extension_installed(&plugin.identifier),
"opencode" => check_opencode_plugin_installed(&plugin.identifier),
_ => continue,
};
if check.is_not_installed() {
report.missing_plugins.push(MissingPlugin {
name: plugin.name.clone(),
client: plugin.client.clone(),
identifier: plugin.identifier.clone(),
bundle: plugin.bundle.clone(),
});
}
}
}
}
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,
plugin_scope: crate::plugin::PluginScope,
) -> 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, &[], plugin_scope)?;
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();
let Ok(entries) = fs::read_dir(source_root) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let hash = hash_item_dir(&path);
for (entrypoint, kind) in [
("RULE.md", ItemKind::Rule),
("SKILL.md", ItemKind::Skill),
("AGENT.md", ItemKind::Agent),
] {
if path.join(entrypoint).is_file() {
out.insert((kind, name.to_string()), hash.clone());
}
}
}
out
}
#[derive(Debug, Clone, Serialize)]
pub struct ListedItem {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub git_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListedBundle {
pub name: String,
pub source: String,
pub git_ref: Option<String>,
pub items: Vec<String>,
}
#[derive(Debug, Default, Clone, Serialize)]
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,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<InstallReport> {
match source {
InstallSource::LocalPath(path) => install_from_local_path(path, target, filter),
InstallSource::Github(repo) => install_from_github(repo, target, filter),
InstallSource::Gitlab(repo) => install_from_gitlab(repo, target, filter),
}
}
fn install_from_github(
repo: &GithubRepo,
target: &Path,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<InstallReport> {
install_from_git_url(
&github_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
target,
filter,
)
}
fn install_from_gitlab(
repo: &GitlabRepo,
target: &Path,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<InstallReport> {
install_from_git_url(
&gitlab_authenticated_url(repo)?,
repo.git_ref.as_deref(),
repo.subfolder.as_deref(),
&repo.owner,
&repo.name,
target,
filter,
)
}
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(), subfolder)
.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,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<InstallReport> {
let tmp = tempfile::tempdir().context("create temp dir for clone")?;
fetch::shallow_clone(url, git_ref, "clone", tmp.path(), subfolder)
.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, filter)
}
fn install_skills(
source: &Path,
target: &Path,
report: &mut InstallReport,
filter: Option<&crate::bundle::ResolvedItems>,
) -> Result<()> {
for (name, dir) in iter_item_dirs(source)? {
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)? {
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)? {
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;
}
if is_item_dir(&path) {
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));
} else {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_dir() && is_item_dir(&sub_path) {
let name = sub_entry
.file_name()
.to_str()
.map(str::to_owned)
.with_context(|| format!("non-UTF8 name in {}", path.display()))?;
out.push((name, sub_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 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")
);
}
}
#[test]
fn find_bundle_by_name_finds_nested_bundle_file() {
let tmp = tempfile::tempdir().unwrap();
let bundles_dir = tmp.path().join("bundles");
std::fs::create_dir_all(&bundles_dir).unwrap();
std::fs::write(
bundles_dir.join("baseline.bundle.yaml"),
"schema: 1\nname: baseline\ndescription: test\nitems:\n rules: []\n",
)
.unwrap();
let result = find_bundle_by_name(tmp.path(), "baseline");
assert_eq!(result, Some(bundles_dir.join("baseline.bundle.yaml")));
}
#[test]
fn find_bundle_by_name_returns_none_when_not_found() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("skills/foo")).unwrap();
std::fs::write(
tmp.path().join("skills/foo/SKILL.md"),
"---\nschema: 1\nname: foo\n---\n# body\n",
)
.unwrap();
let result = find_bundle_by_name(tmp.path(), "foo");
assert!(result.is_none());
}
#[test]
fn find_bundle_by_name_skips_hidden_dirs() {
let tmp = tempfile::tempdir().unwrap();
let hidden = tmp.path().join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(
hidden.join("secret.bundle.yaml"),
"schema: 1\nname: secret\ndescription: x\nitems:\n rules: []\n",
)
.unwrap();
let result = find_bundle_by_name(tmp.path(), "secret");
assert!(result.is_none());
}
#[test]
fn has_matching_items_true_when_skill_exists() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("code-review");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nschema: 1\nname: code-review\n---\n# body\n",
)
.unwrap();
assert!(has_matching_items(tmp.path(), "code-review"));
}
#[test]
fn has_matching_items_false_when_no_item_exists() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("other")).unwrap();
assert!(!has_matching_items(tmp.path(), "nonexistent"));
}
#[test]
fn has_matching_items_true_for_rules_and_agents() {
let tmp = tempfile::tempdir().unwrap();
let rule_dir = tmp.path().join("my-rule");
std::fs::create_dir_all(&rule_dir).unwrap();
std::fs::write(
rule_dir.join("RULE.md"),
"---\nschema: 1\nname: my-rule\n---\n# body\n",
)
.unwrap();
assert!(has_matching_items(tmp.path(), "my-rule"));
}
#[test]
fn has_ssot_layout_detects_direct_children() {
let tmp = tempfile::tempdir().unwrap();
let item = tmp.path().join("my-rule");
std::fs::create_dir_all(&item).unwrap();
std::fs::write(item.join("RULE.md"), "").unwrap();
assert!(has_ssot_layout(tmp.path()));
}
#[test]
fn has_ssot_layout_detects_grandchild_entrypoints() {
let tmp = tempfile::tempdir().unwrap();
let item = tmp.path().join("skills/my-rule");
std::fs::create_dir_all(&item).unwrap();
std::fs::write(item.join("RULE.md"), "").unwrap();
assert!(
has_ssot_layout(tmp.path()),
"has_ssot_layout must detect items nested one level deeper"
);
}
#[test]
fn has_ssot_layout_returns_false_for_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
assert!(!has_ssot_layout(tmp.path()));
}
#[test]
fn find_registry_root_returns_parent_for_sibling_layout() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
std::fs::create_dir_all(registry.join("bundles")).unwrap();
std::fs::create_dir_all(registry.join("skills/my-rule")).unwrap();
std::fs::write(registry.join("skills/my-rule/RULE.md"), "").unwrap();
let bundle = registry.join("bundles/test.bundle.yaml");
std::fs::write(&bundle, "").unwrap();
let root = find_registry_root(&bundle).unwrap();
assert_eq!(root, registry);
}
#[test]
fn iter_item_dirs_finds_items_in_category_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let item = tmp.path().join("skills/my-skill");
std::fs::create_dir_all(&item).unwrap();
std::fs::write(item.join("SKILL.md"), "").unwrap();
let dirs = iter_item_dirs(tmp.path()).unwrap();
let names: Vec<&str> = dirs.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"my-skill"),
"iter_item_dirs must find items in category subdirectories: {names:?}"
);
}
#[test]
fn iter_item_dirs_still_finds_direct_children() {
let tmp = tempfile::tempdir().unwrap();
let item = tmp.path().join("my-rule");
std::fs::create_dir_all(&item).unwrap();
std::fs::write(item.join("RULE.md"), "").unwrap();
let dirs = iter_item_dirs(tmp.path()).unwrap();
let names: Vec<&str> = dirs.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"my-rule"),
"iter_item_dirs must still find direct item children: {names:?}"
);
}
}