use std::cell::Cell;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use skillfile_core::conflict::{read_conflict, write_conflict};
use skillfile_core::error::SkillfileError;
use skillfile_core::lock::{lock_key, read_lock};
use skillfile_core::models::{
short_sha, ConflictState, Entry, InstallOptions, InstallTarget, Manifest,
};
use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
use skillfile_core::patch::{
apply_patch_pure, dir_patch_path, generate_patch, has_patch, patch_path, patches_root,
read_patch, remove_dir_patch, remove_patch, walkdir, write_dir_patch, write_patch,
};
use skillfile_core::progress;
use skillfile_sources::strategy::{content_file, is_dir_entry};
use skillfile_sources::sync::{cmd_sync, vendor_dir_for};
use crate::adapter::{adapters, AdapterScope, DeployRequest, DirInstallMode, PlatformAdapter};
use crate::paths::source_path;
static SNAPSHOT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn to_patch_conflict(err: &SkillfileError, entry_name: &str) -> SkillfileError {
SkillfileError::PatchConflict {
message: err.to_string(),
entry_name: entry_name.to_string(),
}
}
struct PatchCtx<'a> {
entry: &'a Entry,
repo_root: &'a Path,
}
fn rebase_single_patch(
ctx: &PatchCtx<'_>,
source: &Path,
patched: &str,
) -> Result<(), SkillfileError> {
let cache_text = std::fs::read_to_string(source)?;
let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
if new_patch.is_empty() {
remove_patch(ctx.entry, ctx.repo_root)?;
} else {
write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
}
Ok(())
}
fn apply_single_file_patch(
ctx: &PatchCtx<'_>,
dest: &Path,
source: &Path,
) -> Result<(), SkillfileError> {
if !has_patch(ctx.entry, ctx.repo_root) {
return Ok(());
}
let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
let original = std::fs::read_to_string(dest)?;
let patched = apply_patch_pure(&original, &patch_text)
.map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
std::fs::write(dest, &patched)?;
rebase_single_patch(ctx, source, &patched)
}
fn apply_dir_patches(
ctx: &PatchCtx<'_>,
installed_files: &HashMap<String, PathBuf>,
source_dir: &Path,
) -> Result<(), SkillfileError> {
let patches_dir = patches_root(ctx.repo_root)
.join(ctx.entry.entity_type.dir_name())
.join(&ctx.entry.name);
if !patches_dir.is_dir() {
return Ok(());
}
let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
.into_iter()
.filter(|p| p.extension().is_some_and(|e| e == "patch"))
.collect();
for patch_file in patch_files {
let Some(rel) = patch_file
.strip_prefix(&patches_dir)
.ok()
.and_then(|p| p.to_str())
.and_then(|s| s.strip_suffix(".patch"))
.map(str::to_string)
else {
continue;
};
let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
continue;
};
let patch_text = std::fs::read_to_string(&patch_file)?;
let original = std::fs::read_to_string(target)?;
let patched = apply_patch_pure(&original, &patch_text)
.map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
std::fs::write(target, &patched)?;
let cache_file = source_dir.join(&rel);
if !cache_file.exists() {
continue;
}
let cache_text = std::fs::read_to_string(&cache_file)?;
let new_patch = generate_patch(&cache_text, &patched, &rel);
if new_patch.is_empty() {
std::fs::remove_file(&patch_file)?;
} else {
write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
}
}
Ok(())
}
fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
match apply_patch_pure(cache_text, patch_text) {
Ok(expected) if installed_text == expected => true, Err(_) => true, Ok(_) => false, }
}
fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
if !has_patch(ctx.entry, ctx.repo_root) {
return false;
}
let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
return false;
};
patch_already_covers(&pt, cache_text, installed_text)
}
fn divergent_auto_pin_error(entry_name: &str, labels: &[String]) -> SkillfileError {
SkillfileError::Install(format!(
"'{entry_name}' has divergent edits across install targets: {} — reconcile them before running `skillfile install --update`",
labels.join(", ")
))
}
fn target_adapter(target: &InstallTarget) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
adapters()
.get(&target.adapter)
.ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{}'", target.adapter)))
}
struct SingleInstalledVariant {
label: String,
content: String,
}
fn installed_single_file_variants(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<Vec<SingleInstalledVariant>, SkillfileError> {
let mut variants = Vec::new();
for target in &manifest.install_targets {
let adapter = target_adapter(target)?;
if !adapter.supports(entry.entity_type) {
continue;
}
let scope = AdapterScope {
scope: target.scope,
repo_root,
};
let path = adapter.installed_path(entry, &scope);
if !path.exists() {
continue;
}
variants.push(SingleInstalledVariant {
label: target.to_string(),
content: std::fs::read_to_string(path)?,
});
}
Ok(variants)
}
fn representative_single_file_content(
entry_name: &str,
cache_text: &str,
variants: &[SingleInstalledVariant],
) -> Result<Option<String>, SkillfileError> {
let modified: Vec<&SingleInstalledVariant> = variants
.iter()
.filter(|variant| variant.content != cache_text)
.collect();
if modified.is_empty() {
return Ok(None);
}
let representative = &modified[0].content;
if modified
.iter()
.any(|variant| variant.content != *representative)
{
let labels: Vec<String> = modified
.iter()
.map(|variant| variant.label.clone())
.collect();
return Err(divergent_auto_pin_error(entry_name, &labels));
}
Ok(Some(representative.clone()))
}
struct AutoPinSingleCtx<'a> {
entry: &'a Entry,
manifest: &'a Manifest,
repo_root: &'a Path,
cache_file: &'a Path,
}
fn auto_pin_single_file_entry(ctx: &AutoPinSingleCtx<'_>) -> Result<(), SkillfileError> {
let cache_text = std::fs::read_to_string(ctx.cache_file)?;
let variants = installed_single_file_variants(ctx.entry, ctx.manifest, ctx.repo_root)?;
let Some(installed_text) =
representative_single_file_content(&ctx.entry.name, &cache_text, &variants)?
else {
return Ok(());
};
let patch_ctx = PatchCtx {
entry: ctx.entry,
repo_root: ctx.repo_root,
};
if should_skip_pin(&patch_ctx, &cache_text, &installed_text) {
return Ok(());
}
let patch_text = generate_patch(
&cache_text,
&installed_text,
&format!("{}.md", ctx.entry.name),
);
if !patch_text.is_empty() && write_patch(ctx.entry, &patch_text, ctx.repo_root).is_ok() {
progress!(
" {}: local changes auto-saved to .skillfile/patches/",
ctx.entry.name
);
}
Ok(())
}
fn auto_pin_entry(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<(), SkillfileError> {
if entry.source_type() == "local" {
return Ok(());
}
let Ok(locked) = read_lock(repo_root) else {
return Ok(());
};
let key = lock_key(entry);
if !locked.contains_key(&key) {
return Ok(());
}
let vdir = vendor_dir_for(entry, repo_root);
if is_dir_entry(entry) {
return auto_pin_dir_entry(entry, manifest, repo_root);
}
let cf = content_file(entry);
if cf.is_empty() {
return Ok(());
}
let cache_file = vdir.join(&cf);
if !cache_file.exists() {
return Ok(());
}
auto_pin_single_file_entry(&AutoPinSingleCtx {
entry,
manifest,
repo_root,
cache_file: &cache_file,
})
}
fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
if !patch_path.exists() {
return false;
}
let Ok(pt) = std::fs::read_to_string(patch_path) else {
return false;
};
patch_already_covers(&pt, cache_text, installed_text)
}
fn load_cache_files(vdir: &Path) -> BTreeMap<String, PathBuf> {
walkdir(vdir)
.into_iter()
.filter(|cache_file| cache_file.file_name().is_none_or(|name| name != ".meta"))
.filter_map(|cache_file| {
let filename = cache_file
.strip_prefix(vdir)
.ok()
.and_then(|path| path.to_str())
.map(str::to_string)?;
Some((filename, cache_file))
})
.collect()
}
struct DirInstalledVariant {
label: String,
files: HashMap<String, PathBuf>,
}
type DirModifiedMap = BTreeMap<String, String>;
fn installed_dir_variants(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<Vec<DirInstalledVariant>, SkillfileError> {
let mut variants = Vec::new();
for target in &manifest.install_targets {
let adapter = target_adapter(target)?;
if !adapter.supports(entry.entity_type) {
continue;
}
let scope = AdapterScope {
scope: target.scope,
repo_root,
};
let files = adapter.installed_dir_files(entry, &scope);
if files.is_empty() {
continue;
}
variants.push(DirInstalledVariant {
label: target.to_string(),
files,
});
}
Ok(variants)
}
fn modified_dir_content(
cache_files: &BTreeMap<String, PathBuf>,
variant: &DirInstalledVariant,
) -> Result<DirModifiedMap, SkillfileError> {
let mut modified = BTreeMap::new();
for (filename, cache_file) in cache_files {
let Some(installed_path) = variant.files.get(filename).filter(|path| path.exists()) else {
continue;
};
let cache_text = std::fs::read_to_string(cache_file)?;
let installed_text = std::fs::read_to_string(installed_path)?;
if installed_text != cache_text {
modified.insert(filename.clone(), installed_text);
}
}
Ok(modified)
}
fn representative_dir_changes(
entry_name: &str,
cache_files: &BTreeMap<String, PathBuf>,
variants: &[DirInstalledVariant],
) -> Result<Option<DirModifiedMap>, SkillfileError> {
let mut modified = Vec::new();
for variant in variants {
let changed = modified_dir_content(cache_files, variant)?;
if !changed.is_empty() {
modified.push((variant.label.clone(), changed));
}
}
if modified.is_empty() {
return Ok(None);
}
let representative = &modified[0].1;
if modified
.iter()
.any(|(_, changed)| changed != representative)
{
let labels: Vec<String> = modified.iter().map(|(label, _)| label.clone()).collect();
return Err(divergent_auto_pin_error(entry_name, &labels));
}
Ok(Some(representative.clone()))
}
struct WriteDirPatchesCtx<'a> {
entry: &'a Entry,
repo_root: &'a Path,
cache_files: &'a BTreeMap<String, PathBuf>,
representative: &'a DirModifiedMap,
}
fn write_auto_pin_dir_patches(ctx: &WriteDirPatchesCtx<'_>) -> Result<Vec<String>, SkillfileError> {
let mut pinned = Vec::new();
for (filename, cache_file) in ctx.cache_files {
let Some(installed_text) = ctx.representative.get(filename) else {
remove_dir_patch(ctx.entry, filename, ctx.repo_root)?;
continue;
};
let cache_text = std::fs::read_to_string(cache_file)?;
let patch_path = dir_patch_path(ctx.entry, filename, ctx.repo_root);
if dir_patch_already_matches(&patch_path, &cache_text, installed_text) {
continue;
}
let patch_text = generate_patch(&cache_text, installed_text, filename);
if patch_text.is_empty() {
continue;
}
write_dir_patch(&patch_path, &patch_text)?;
pinned.push(filename.clone());
}
Ok(pinned)
}
fn auto_pin_dir_entry(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<(), SkillfileError> {
let vdir = &vendor_dir_for(entry, repo_root);
if !vdir.is_dir() {
return Ok(());
}
let installed = installed_dir_variants(entry, manifest, repo_root)?;
if installed.is_empty() {
return Ok(());
}
let cache_files = load_cache_files(vdir);
let Some(representative) = representative_dir_changes(&entry.name, &cache_files, &installed)?
else {
return Ok(());
};
let pinned = write_auto_pin_dir_patches(&WriteDirPatchesCtx {
entry,
repo_root,
cache_files: &cache_files,
representative: &representative,
})?;
if !pinned.is_empty() {
progress!(
" {}: local changes auto-saved to .skillfile/patches/ ({})",
entry.name,
pinned.join(", ")
);
}
Ok(())
}
pub struct InstallCtx<'a> {
pub repo_root: &'a Path,
pub opts: Option<&'a InstallOptions>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSkipReason {
UnknownAdapter,
UnsupportedEntity,
MissingSource,
NothingDeployed,
DryRun,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
Installed,
Skipped(InstallSkipReason),
}
pub fn install_entry(
entry: &Entry,
target: &InstallTarget,
ctx: &InstallCtx<'_>,
) -> Result<(), SkillfileError> {
let _ = install_entry_with_outcome(entry, target, ctx)?;
Ok(())
}
fn install_failure(entry: &Entry, target: &InstallTarget, detail: &str) -> SkillfileError {
SkillfileError::Install(format!(
"failed to install '{}' to {target}: {detail}",
entry.name
))
}
struct PathSnapshot {
live_path: PathBuf,
snapshot_path: Option<PathBuf>,
}
#[derive(Default)]
pub struct InstallSnapshot {
scratch_dir: Option<PathBuf>,
paths: Vec<PathSnapshot>,
preserve_scratch: Cell<bool>,
}
impl Drop for InstallSnapshot {
fn drop(&mut self) {
if self.preserve_scratch.get() {
return;
}
if let Some(path) = &self.scratch_dir {
let _ = std::fs::remove_dir_all(path);
remove_empty_dir(path.parent());
}
}
}
fn remove_empty_dir(path: Option<&Path>) {
let Some(path) = path else {
return;
};
if path
.read_dir()
.is_ok_and(|mut entries| entries.next().is_none())
{
let _ = std::fs::remove_dir(path);
}
}
fn remove_path(path: &Path) -> std::io::Result<()> {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return Ok(());
};
let file_type = metadata.file_type();
#[cfg(windows)]
{
use std::os::windows::fs::FileTypeExt as _;
if file_type.is_symlink_dir() {
return std::fs::remove_dir(path);
}
}
if file_type.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path, _is_dir: bool) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path, is_dir: bool) -> std::io::Result<()> {
if is_dir {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
}
#[cfg(windows)]
fn symlink_is_dir(path: &Path) -> std::io::Result<bool> {
use std::os::windows::fs::FileTypeExt as _;
Ok(std::fs::symlink_metadata(path)?
.file_type()
.is_symlink_dir())
}
#[cfg(windows)]
fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
let target = std::fs::read_link(source)?;
create_symlink(&target, dest, symlink_is_dir(source)?)
}
#[cfg(not(windows))]
fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
let target = std::fs::read_link(source)?;
create_symlink(&target, dest, false)
}
fn copy_path(source: &Path, dest: &Path) -> std::io::Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let metadata = std::fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
copy_symlink(source, dest)
} else if metadata.is_dir() {
copy_snapshot_dir(source, dest)
} else {
std::fs::copy(source, dest).map(|_| ())
}
}
fn copy_snapshot_dir(source: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let dest_path = dest.join(entry.file_name());
copy_path(&source_path, &dest_path)?;
}
Ok(())
}
fn snapshot_scratch_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = SNAPSHOT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = repo_root.join(".skillfile").join("tmp").join(format!(
"install-snapshot-{}-{stamp}-{seq}",
std::process::id()
));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn capture_path_snapshot(
live_path: PathBuf,
scratch_dir: &Path,
index: usize,
) -> std::io::Result<PathSnapshot> {
let snapshot_path = if live_path.exists() || live_path.is_symlink() {
let snapshot_path = scratch_dir.join(index.to_string());
copy_path(&live_path, &snapshot_path)?;
Some(snapshot_path)
} else {
None
};
Ok(PathSnapshot {
live_path,
snapshot_path,
})
}
impl InstallSnapshot {
fn capture(repo_root: &Path, paths: Vec<PathBuf>) -> Result<Self, SkillfileError> {
let mut seen = HashSet::new();
let paths: Vec<PathBuf> = paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect();
if paths.is_empty() {
return Ok(Self::default());
}
let scratch_dir = snapshot_scratch_dir(repo_root)?;
let mut snapshot = Self {
scratch_dir: Some(scratch_dir.clone()),
paths: Vec::new(),
preserve_scratch: Cell::new(false),
};
for (index, path) in paths.into_iter().enumerate() {
snapshot
.paths
.push(capture_path_snapshot(path, &scratch_dir, index)?);
}
Ok(snapshot)
}
pub fn restore(&self) -> Result<(), SkillfileError> {
for snapshot in self.paths.iter().rev() {
self.restore_path(snapshot)?;
}
Ok(())
}
fn scratch_dir(&self) -> Option<&Path> {
self.scratch_dir.as_deref()
}
fn restore_path(&self, snapshot: &PathSnapshot) -> Result<(), SkillfileError> {
let result = restore_path_snapshot(snapshot);
if result.is_err() {
self.preserve_scratch.set(true);
}
result.map_err(SkillfileError::from)
}
}
fn restore_path_snapshot(snapshot: &PathSnapshot) -> std::io::Result<()> {
remove_path(&snapshot.live_path)?;
let Some(snapshot_path) = &snapshot.snapshot_path else {
return Ok(());
};
copy_path(snapshot_path, &snapshot.live_path)
}
fn forward_slash(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
struct InstallValidationCtx<'a> {
entry: &'a Entry,
target: &'a InstallTarget,
repo_root: &'a Path,
source: &'a Path,
adapter: &'a dyn PlatformAdapter,
is_dir: bool,
opts: &'a InstallOptions,
}
struct InstallPlan {
expected: HashMap<String, PathBuf>,
existing_before: HashSet<String>,
}
struct ValidatedInstall {
installed: HashMap<String, PathBuf>,
outcome: InstallOutcome,
}
fn flat_expected_paths(source: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
walkdir(source)
.into_iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "md"))
.filter_map(|path| {
let rel = path.strip_prefix(source).ok()?;
let name = path.file_name()?;
Some((forward_slash(rel), target_dir.join(name)))
})
.collect()
}
fn nested_expected_paths(source: &Path, dest_root: &Path) -> HashMap<String, PathBuf> {
walkdir(source)
.into_iter()
.filter(|path| path.file_name().is_none_or(|name| name != ".meta"))
.filter_map(|path| {
let rel = path.strip_prefix(source).ok()?;
Some((forward_slash(rel), dest_root.join(rel)))
})
.collect()
}
fn planned_install_paths(ctx: &InstallValidationCtx<'_>) -> HashMap<String, PathBuf> {
let scope = AdapterScope {
scope: ctx.target.scope,
repo_root: ctx.repo_root,
};
let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
if !ctx.is_dir {
let key = format!("{}.md", ctx.entry.name);
return HashMap::from([(key, ctx.adapter.installed_path(ctx.entry, &scope))]);
}
match ctx.adapter.dir_mode(ctx.entry.entity_type) {
Some(DirInstallMode::Flat) => flat_expected_paths(ctx.source, &target_dir),
_ => nested_expected_paths(ctx.source, &target_dir.join(&ctx.entry.name)),
}
}
fn patch_effect_path(ctx: &InstallValidationCtx<'_>) -> PathBuf {
if ctx.is_dir {
patches_root(ctx.repo_root)
.join(ctx.entry.entity_type.dir_name())
.join(&ctx.entry.name)
} else {
patch_path(ctx.entry, ctx.repo_root)
}
}
fn install_effect_paths(ctx: &InstallValidationCtx<'_>) -> Vec<PathBuf> {
let scope = AdapterScope {
scope: ctx.target.scope,
repo_root: ctx.repo_root,
};
let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
let mut paths = if !ctx.is_dir {
vec![ctx.adapter.installed_path(ctx.entry, &scope)]
} else if ctx.adapter.dir_mode(ctx.entry.entity_type) == Some(DirInstallMode::Flat) {
flat_expected_paths(ctx.source, &target_dir)
.into_values()
.collect()
} else {
vec![target_dir.join(&ctx.entry.name)]
};
if ctx.adapter.dir_mode(ctx.entry.entity_type) != Some(DirInstallMode::Flat) {
paths.push(target_dir.join(format!("{}.md", ctx.entry.name)));
}
paths.push(patch_effect_path(ctx));
paths
}
pub fn capture_install_snapshot(
entry: &Entry,
targets: &[InstallTarget],
repo_root: &Path,
) -> Result<InstallSnapshot, SkillfileError> {
let mut paths = Vec::new();
for target in targets {
paths.extend(install_effect_paths_for_target(entry, target, repo_root));
}
InstallSnapshot::capture(repo_root, paths)
}
fn install_effect_paths_for_target(
entry: &Entry,
target: &InstallTarget,
repo_root: &Path,
) -> Vec<PathBuf> {
let all_adapters = adapters();
let Some(adapter) = all_adapters.get(&target.adapter) else {
return Vec::new();
};
if !adapter.supports(entry.entity_type) {
return Vec::new();
}
let Some(source) = source_path(entry, repo_root).filter(|path| path.exists()) else {
return Vec::new();
};
let default_opts = InstallOptions::default();
let validation_ctx = InstallValidationCtx {
entry,
target,
repo_root,
source: &source,
adapter,
is_dir: is_dir_entry(entry) || source.is_dir(),
opts: &default_opts,
};
install_effect_paths(&validation_ctx)
}
fn build_install_plan(ctx: &InstallValidationCtx<'_>) -> InstallPlan {
let expected = planned_install_paths(ctx);
let existing_before = expected
.iter()
.filter_map(|(key, path)| path.is_file().then_some(key.clone()))
.collect();
InstallPlan {
expected,
existing_before,
}
}
fn cleanup_created_files(plan: &InstallPlan, installed: &HashMap<String, PathBuf>) {
for (key, path) in installed {
if plan.existing_before.contains(key) {
continue;
}
let _ = std::fs::remove_file(path);
}
}
fn validate_installed_files(
ctx: &InstallValidationCtx<'_>,
plan: &InstallPlan,
installed: HashMap<String, PathBuf>,
) -> Result<ValidatedInstall, SkillfileError> {
let reported_count = installed.len();
let existing = installed
.into_iter()
.filter(|(_, path)| path.is_file())
.collect::<HashMap<_, _>>();
if existing.len() != reported_count {
return Err(install_failure(
ctx.entry,
ctx.target,
"adapter reported installed files that do not exist on disk",
));
}
let expected = plan.expected.len();
if expected == 0 {
return Ok(ValidatedInstall {
installed: existing,
outcome: InstallOutcome::Skipped(InstallSkipReason::NothingDeployed),
});
}
let present = plan.expected.values().filter(|path| path.is_file()).count();
if present == 0 {
cleanup_created_files(plan, &existing);
return Err(install_failure(
ctx.entry,
ctx.target,
"no files were written to the target platform directory",
));
}
if present < expected {
cleanup_created_files(plan, &existing);
return Err(install_failure(
ctx.entry,
ctx.target,
&format!("only {present} of {expected} expected file(s) were written"),
));
}
let outcome = if ctx.opts.overwrite || plan.existing_before.len() != expected {
InstallOutcome::Installed
} else {
InstallOutcome::Skipped(InstallSkipReason::NothingDeployed)
};
Ok(ValidatedInstall {
installed: existing,
outcome,
})
}
fn restore_on_install_error(snapshot: &InstallSnapshot, error: SkillfileError) -> SkillfileError {
match snapshot.restore() {
Ok(()) => error,
Err(rollback_error) => {
let snapshot_hint = snapshot.scratch_dir().map_or_else(String::new, |path| {
format!("; rollback snapshot kept at {}", path.display())
});
let rollback_detail = format!(
"rollback failed: {rollback_error}{snapshot_hint}; target may need manual cleanup"
);
match error {
SkillfileError::PatchConflict {
message,
entry_name,
} => SkillfileError::PatchConflict {
message: format!("{message}; {rollback_detail}"),
entry_name,
},
other => SkillfileError::Install(format!("{other}; {rollback_detail}")),
}
}
}
}
fn deploy_and_patch_entry(
validation_ctx: &InstallValidationCtx<'_>,
plan: &InstallPlan,
) -> Result<InstallOutcome, SkillfileError> {
let installed = validation_ctx.adapter.deploy_entry(&DeployRequest {
entry: validation_ctx.entry,
source: validation_ctx.source,
scope: validation_ctx.target.scope,
repo_root: validation_ctx.repo_root,
opts: validation_ctx.opts,
});
if validation_ctx.opts.dry_run {
return Ok(InstallOutcome::Skipped(InstallSkipReason::DryRun));
}
let validated = validate_installed_files(validation_ctx, plan, installed)?;
if let InstallOutcome::Skipped(reason) = validated.outcome {
return Ok(InstallOutcome::Skipped(reason));
}
let patch_ctx = PatchCtx {
entry: validation_ctx.entry,
repo_root: validation_ctx.repo_root,
};
if validation_ctx.is_dir {
apply_dir_patches(&patch_ctx, &validated.installed, validation_ctx.source)?;
} else {
let key = format!("{}.md", validation_ctx.entry.name);
if let Some(dest) = validated.installed.get(&key) {
apply_single_file_patch(&patch_ctx, dest, validation_ctx.source)?;
}
}
Ok(InstallOutcome::Installed)
}
pub fn install_entry_with_outcome(
entry: &Entry,
target: &InstallTarget,
ctx: &InstallCtx<'_>,
) -> Result<InstallOutcome, SkillfileError> {
let default_opts = InstallOptions::default();
let opts = ctx.opts.unwrap_or(&default_opts);
let all_adapters = adapters();
let Some(adapter) = all_adapters.get(&target.adapter) else {
return Ok(InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter));
};
if !adapter.supports(entry.entity_type) {
return Ok(InstallOutcome::Skipped(
InstallSkipReason::UnsupportedEntity,
));
}
let source = match source_path(entry, ctx.repo_root) {
Some(p) if p.exists() => p,
_ => {
eprintln!(" warning: source missing for {}, skipping", entry.name);
return Ok(InstallOutcome::Skipped(InstallSkipReason::MissingSource));
}
};
let is_dir = is_dir_entry(entry) || source.is_dir();
let validation_ctx = InstallValidationCtx {
entry,
target,
repo_root: ctx.repo_root,
source: &source,
adapter,
is_dir,
opts,
};
let plan = build_install_plan(&validation_ctx);
let snapshot = if opts.dry_run {
InstallSnapshot::default()
} else {
InstallSnapshot::capture(ctx.repo_root, install_effect_paths(&validation_ctx))?
};
deploy_and_patch_entry(&validation_ctx, &plan)
.map_err(|error| restore_on_install_error(&snapshot, error))
}
fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
if manifest.install_targets.is_empty() {
return Err(SkillfileError::Manifest(
"No install targets configured. Run `skillfile init` first.".into(),
));
}
if let Some(conflict) = read_conflict(repo_root)? {
return Err(SkillfileError::Install(format!(
"pending conflict for '{}' — \
run `skillfile diff {}` to review, \
or `skillfile resolve {}` to merge",
conflict.entry, conflict.entry, conflict.entry
)));
}
Ok(())
}
fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
format!(
"\n upstream: {} \u{2192} {}",
short_sha(old_sha),
short_sha(new_sha)
)
} else {
String::new()
}
}
struct LockMaps<'a> {
locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
}
struct DeployCtx<'a> {
repo_root: &'a Path,
opts: &'a InstallOptions,
maps: LockMaps<'a>,
}
fn handle_patch_conflict(
entry: &Entry,
entry_name: &str,
ctx: &DeployCtx<'_>,
) -> Result<(), SkillfileError> {
let key = lock_key(entry);
let old_sha = ctx
.maps
.old_locked
.get(&key)
.map(|l| l.sha.clone())
.unwrap_or_default();
let new_sha = ctx
.maps
.locked
.get(&key)
.map_or_else(|| old_sha.clone(), |l| l.sha.clone());
write_conflict(
ctx.repo_root,
&ConflictState {
entry: entry_name.to_string(),
entity_type: entry.entity_type,
old_sha: old_sha.clone(),
new_sha: new_sha.clone(),
},
)?;
let sha_info = sha_transition_hint(&old_sha, &new_sha);
Err(SkillfileError::Install(format!(
"upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
Your pinned edits could not be applied to the new upstream version.\n\
Run `skillfile diff {entry_name}` to review what changed upstream.\n\
Run `skillfile resolve {entry_name}` when ready to merge.\n\
Run `skillfile resolve --abort` to discard the conflict and keep the old version."
)))
}
fn append_rollback_detail(error: SkillfileError, patch_message: &str) -> SkillfileError {
if !patch_message.contains("rollback failed") {
return error;
}
match error {
SkillfileError::Install(message) => {
SkillfileError::Install(format!("{message}\nRollback warning: {patch_message}"))
}
other => other,
}
}
fn install_entry_or_conflict(
entry: &Entry,
target: &InstallTarget,
ctx: &DeployCtx<'_>,
) -> Result<(), SkillfileError> {
let install_ctx = InstallCtx {
repo_root: ctx.repo_root,
opts: Some(ctx.opts),
};
match install_entry(entry, target, &install_ctx) {
Ok(()) => Ok(()),
Err(SkillfileError::PatchConflict {
entry_name,
message,
}) => handle_patch_conflict(entry, &entry_name, ctx)
.map_err(|error| append_rollback_detail(error, &message)),
Err(e) => Err(e),
}
}
fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
let all_adapters = adapters();
for target in &manifest.install_targets {
if !all_adapters.contains(&target.adapter) {
eprintln!("warning: unknown platform '{}', skipping", target.adapter);
continue;
}
progress!(
"Installing for {} ({}){mode}...",
target.adapter,
target.scope
);
for entry in &manifest.entries {
install_entry_or_conflict(entry, target, ctx)?;
}
}
Ok(())
}
fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
let Some(targets) = extra_targets else {
return;
};
if !targets.is_empty() {
progress!("Using platform targets from personal config (Skillfile has no install lines).");
}
manifest.install_targets = targets.to_vec();
}
fn load_manifest(
repo_root: &Path,
extra_targets: Option<&[InstallTarget]>,
) -> Result<Manifest, SkillfileError> {
let manifest_path = repo_root.join(MANIFEST_NAME);
if !manifest_path.exists() {
return Err(SkillfileError::Manifest(format!(
"{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
repo_root.display()
)));
}
let result = parse_manifest(&manifest_path)?;
for w in &result.warnings {
eprintln!("{w}");
}
let mut manifest = result.manifest;
if manifest.install_targets.is_empty() {
apply_extra_targets(&mut manifest, extra_targets);
}
Ok(manifest)
}
fn auto_pin_all(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
for entry in &manifest.entries {
auto_pin_entry(entry, manifest, repo_root)?;
}
Ok(())
}
fn print_first_install_hint(manifest: &Manifest) {
let platforms: Vec<String> = manifest
.install_targets
.iter()
.map(|t| format!("{} ({})", t.adapter, t.scope))
.collect();
progress!(" Configured platforms: {}", platforms.join(", "));
progress!(" Run `skillfile init` to add or change platforms.");
}
pub struct CmdInstallOpts<'a> {
pub dry_run: bool,
pub update: bool,
pub extra_targets: Option<&'a [InstallTarget]>,
}
pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
let manifest = load_manifest(repo_root, opts.extra_targets)?;
check_preconditions(&manifest, repo_root)?;
let cache_dir = repo_root.join(".skillfile").join("cache");
let first_install = !cache_dir.exists();
let old_locked = read_lock(repo_root).unwrap_or_default();
if opts.update && !opts.dry_run {
auto_pin_all(&manifest, repo_root)?;
}
if !opts.dry_run {
std::fs::create_dir_all(&cache_dir)?;
}
cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
repo_root,
dry_run: opts.dry_run,
entry_filter: None,
update: opts.update,
})?;
let locked = read_lock(repo_root).unwrap_or_default();
let install_opts = InstallOptions {
dry_run: opts.dry_run,
overwrite: opts.update,
};
let deploy_ctx = DeployCtx {
repo_root,
opts: &install_opts,
maps: LockMaps {
locked: &locked,
old_locked: &old_locked,
},
};
deploy_all(&manifest, &deploy_ctx)?;
if !opts.dry_run {
progress!("Done.");
if first_install {
print_first_install_hint(&manifest);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use skillfile_core::models::{
EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn patch_fixture_path(dir: &Path, entry: &Entry) -> PathBuf {
dir.join(".skillfile/patches")
.join(entry.entity_type.dir_name())
.join(format!("{}.patch", entry.name))
}
fn dir_patch_fixture_path(dir: &Path, entry: &Entry, rel: &str) -> PathBuf {
dir.join(".skillfile/patches")
.join(entry.entity_type.dir_name())
.join(&entry.name)
.join(format!("{rel}.patch"))
}
fn write_patch_fixture(dir: &Path, entry: &Entry, text: &str) {
let p = patch_fixture_path(dir, entry);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
fn write_lock_fixture(dir: &Path, locked: &BTreeMap<String, LockEntry>) {
let json = serde_json::to_string_pretty(locked).unwrap();
std::fs::write(dir.join("Skillfile.lock"), format!("{json}\n")).unwrap();
}
fn write_conflict_fixture(dir: &Path, state: &ConflictState) {
let p = dir.join(".skillfile/conflict");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let json = serde_json::to_string_pretty(state).unwrap();
std::fs::write(p, format!("{json}\n")).unwrap();
}
fn has_dir_patch_fixture(dir: &Path, entry: &Entry) -> bool {
let d = dir
.join(".skillfile/patches")
.join(entry.entity_type.dir_name())
.join(&entry.name);
if !d.is_dir() {
return false;
}
std::fs::read_dir(&d).is_ok_and(|rd| {
rd.filter_map(std::result::Result::ok)
.any(|e| e.path().extension().is_some_and(|x| x == "patch"))
})
}
fn make_agent_entry(name: &str) -> Entry {
Entry {
entity_type: EntityType::Agent,
name: name.into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "agents/agent.md".into(),
ref_: "main".into(),
},
}
}
fn make_local_entry(name: &str, path: &str) -> Entry {
Entry {
entity_type: EntityType::Skill,
name: name.into(),
source: SourceFields::Local { path: path.into() },
}
}
fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
InstallTarget {
adapter: adapter.into(),
scope,
}
}
#[test]
fn install_local_entry_copy() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("claude-code", Scope::Local);
let outcome = install_entry_with_outcome(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(outcome, InstallOutcome::Installed);
let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
}
#[test]
fn install_local_dir_entry_copy() {
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().join("skills/python-testing");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
let entry = make_local_entry("python-testing", "skills/python-testing");
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".claude/skills/python-testing");
assert!(dest.is_dir(), "local dir entry must deploy as directory");
assert_eq!(
std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
"# Python Testing"
);
assert_eq!(
std::fs::read_to_string(dest.join("examples.md")).unwrap(),
"# Examples"
);
assert!(
!dir.path().join(".claude/skills/python-testing.md").exists(),
"should not create python-testing.md for a dir source"
);
}
#[test]
fn install_entry_dry_run_no_write() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("claude-code", Scope::Local);
let opts = InstallOptions {
dry_run: true,
..Default::default()
};
let outcome = install_entry_with_outcome(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: Some(&opts),
},
)
.unwrap();
assert_eq!(outcome, InstallOutcome::Skipped(InstallSkipReason::DryRun));
let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
assert!(!dest.exists());
}
#[test]
fn install_entry_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# New content").unwrap();
let dest_dir = dir.path().join(".claude/skills/my-skill");
std::fs::create_dir_all(&dest_dir).unwrap();
let dest = dest_dir.join("SKILL.md");
std::fs::write(&dest, "# Old content").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
}
#[test]
fn install_github_entry_copy() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
let entry = make_agent_entry("my-agent");
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".claude/agents/my-agent.md");
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
}
#[test]
fn install_github_dir_entry_copy() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
let entry = Entry {
entity_type: EntityType::Skill,
name: "python-pro".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/python-pro".into(),
ref_: "main".into(),
},
};
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".claude/skills/python-pro");
assert!(dest.is_dir());
assert_eq!(
std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
"# Python Pro"
);
}
#[test]
fn install_agent_dir_entry_explodes_to_individual_files() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
std::fs::write(vdir.join(".meta"), "{}").unwrap();
let entry = Entry {
entity_type: EntityType::Agent,
name: "core-dev".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "categories/core-dev".into(),
ref_: "main".into(),
},
};
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let agents_dir = dir.path().join(".claude/agents");
assert_eq!(
std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
"# Backend"
);
assert_eq!(
std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
"# Frontend"
);
assert!(!agents_dir.join("core-dev").exists());
}
#[test]
fn install_entry_missing_source_warns() {
let dir = tempfile::tempdir().unwrap();
let entry = make_agent_entry("my-agent");
let target = make_target("claude-code", Scope::Local);
let outcome = install_entry_with_outcome(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
outcome,
InstallOutcome::Skipped(InstallSkipReason::MissingSource)
);
}
#[test]
fn install_entry_unknown_adapter_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("unknown-adapter", Scope::Local);
let outcome = install_entry_with_outcome(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
outcome,
InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter)
);
}
#[test]
fn install_entry_errors_when_target_path_is_blocked() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
std::fs::write(dir.path().join(".claude"), "not a directory").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("claude-code", Scope::Local);
let result = install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
);
assert!(matches!(
result,
Err(SkillfileError::Install(message))
if message.contains("failed to install 'my-skill' to claude-code (local)")
));
}
#[test]
fn install_snapshot_restores_previous_directory_contents() {
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().join("skills/foo");
let dest_dir = dir.path().join(".claude/skills/foo");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::create_dir_all(&dest_dir).unwrap();
std::fs::write(source_dir.join("SKILL.md"), "# Source\n").unwrap();
std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
let entry = make_local_entry("foo", "skills/foo");
let target = make_target("claude-code", Scope::Local);
let snapshot =
capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
std::fs::remove_dir_all(&dest_dir).unwrap();
std::fs::create_dir_all(&dest_dir).unwrap();
std::fs::write(dest_dir.join("SKILL.md"), "# New\n").unwrap();
snapshot.restore().unwrap();
assert_eq!(
std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
"# Old\n"
);
}
#[test]
fn install_snapshot_restores_legacy_flat_file_side_effect() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/foo.md");
let legacy_file = dir.path().join(".claude/skills/foo.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::create_dir_all(legacy_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Source\n").unwrap();
std::fs::write(&legacy_file, "# Legacy\n").unwrap();
let entry = make_local_entry("foo", "skills/foo.md");
let target = make_target("claude-code", Scope::Local);
let snapshot =
capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
std::fs::remove_file(&legacy_file).unwrap();
snapshot.restore().unwrap();
assert_eq!(std::fs::read_to_string(&legacy_file).unwrap(), "# Legacy\n");
}
#[test]
fn install_snapshot_restores_patch_file() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Source\n").unwrap();
let entry = make_local_entry("foo", "skills/foo.md");
let patch = patch_fixture_path(dir.path(), &entry);
std::fs::create_dir_all(patch.parent().unwrap()).unwrap();
std::fs::write(&patch, "old patch").unwrap();
let target = make_target("claude-code", Scope::Local);
let snapshot =
capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
std::fs::write(&patch, "new patch").unwrap();
snapshot.restore().unwrap();
assert_eq!(std::fs::read_to_string(&patch).unwrap(), "old patch");
}
#[cfg(unix)]
#[test]
fn install_entry_restores_existing_directory_when_copy_fails() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().join("skills/foo");
let dest_dir = dir.path().join(".claude/skills/foo");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::create_dir_all(&dest_dir).unwrap();
std::fs::write(source_dir.join("SKILL.md"), "# New\n").unwrap();
symlink("missing-target.md", source_dir.join("dangling.md")).unwrap();
std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
std::fs::write(dest_dir.join("keep.md"), "# Keep\n").unwrap();
let entry = make_local_entry("foo", "skills/foo");
let target = make_target("claude-code", Scope::Local);
let result = install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
);
assert!(matches!(
result,
Err(SkillfileError::Install(message))
if message.contains("failed to install 'foo' to claude-code (local)")
));
assert_eq!(
std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
"# Old\n"
);
assert_eq!(
std::fs::read_to_string(dest_dir.join("keep.md")).unwrap(),
"# Keep\n"
);
}
#[cfg(unix)]
#[test]
fn install_snapshot_restores_symlink_as_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join(".claude/skills/foo.md");
std::fs::create_dir_all(link.parent().unwrap()).unwrap();
symlink("target.md", &link).unwrap();
let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
std::fs::remove_file(&link).unwrap();
std::fs::write(&link, "# regular file\n").unwrap();
snapshot.restore().unwrap();
assert!(std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
std::fs::read_link(&link).unwrap(),
PathBuf::from("target.md")
);
}
#[cfg(windows)]
#[test]
fn install_snapshot_restores_dangling_directory_symlink_kind() {
use std::os::windows::fs::{symlink_dir, FileTypeExt as _};
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join(".claude/skills/foo");
std::fs::create_dir_all(link.parent().unwrap()).unwrap();
if symlink_dir("missing-dir", &link).is_err() {
return;
}
let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
remove_path(&link).unwrap();
std::fs::write(&link, "# regular file\n").unwrap();
snapshot.restore().unwrap();
let file_type = std::fs::symlink_metadata(&link).unwrap().file_type();
assert!(file_type.is_symlink_dir());
assert_eq!(
std::fs::read_link(&link).unwrap(),
PathBuf::from("missing-dir")
);
}
#[test]
fn install_snapshot_keeps_scratch_dir_when_restore_fails() {
let dir = tempfile::tempdir().unwrap();
let live_path = dir.path().join("target/foo.md");
std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
std::fs::write(&live_path, "# old\n").unwrap();
let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
let result = snapshot.restore();
assert!(result.is_err());
drop(snapshot);
assert!(scratch_dir.exists());
std::fs::remove_dir_all(&scratch_dir).unwrap();
remove_empty_dir(scratch_dir.parent());
}
#[test]
fn patch_conflict_type_survives_rollback_failure() {
let dir = tempfile::tempdir().unwrap();
let live_path = dir.path().join("target/foo.md");
std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
std::fs::write(&live_path, "# old\n").unwrap();
let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
let error = restore_on_install_error(
&snapshot,
SkillfileError::PatchConflict {
message: "patch failed".to_string(),
entry_name: "foo".to_string(),
},
);
assert!(matches!(
error,
SkillfileError::PatchConflict { ref message, ref entry_name }
if entry_name == "foo"
&& message.contains("patch failed")
&& message.contains("rollback failed")
));
drop(snapshot);
std::fs::remove_dir_all(&scratch_dir).unwrap();
remove_empty_dir(scratch_dir.parent());
}
#[test]
fn install_applies_existing_patch() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/skills/test");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
let entry = Entry {
entity_type: EntityType::Skill,
name: "test".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/test.md".into(),
ref_: "main".into(),
},
};
let patch_text =
"--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Test\n \n-Original.\n+Modified.\n";
write_patch_fixture(dir.path(), &entry, patch_text);
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".claude/skills/test/SKILL.md");
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
"# Test\n\nModified.\n"
);
}
#[test]
fn install_patch_conflict_returns_error() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/skills/test");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
let entry = Entry {
entity_type: EntityType::Skill,
name: "test".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/test.md".into(),
ref_: "main".into(),
},
};
let bad_patch =
"--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
write_patch_fixture(dir.path(), &entry, bad_patch);
let installed_dir = dir.path().join(".claude/skills/test");
std::fs::create_dir_all(&installed_dir).unwrap();
std::fs::write(
installed_dir.join("SKILL.md"),
"totally different\ncontent\n",
)
.unwrap();
let target = make_target("claude-code", Scope::Local);
let result = install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
);
assert!(result.is_err());
matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
}
#[test]
fn install_patch_conflict_restores_previous_installed_file() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/skills/test");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("test.md"), "# New upstream\n").unwrap();
let entry = Entry {
entity_type: EntityType::Skill,
name: "test".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/test.md".into(),
ref_: "main".into(),
},
};
let bad_patch =
"--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
write_patch_fixture(dir.path(), &entry, bad_patch);
let dest = dir.path().join(".claude/skills/test/SKILL.md");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
std::fs::write(&dest, "# Old installed\n").unwrap();
let target = make_target("claude-code", Scope::Local);
let result = install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
);
assert!(matches!(result, Err(SkillfileError::PatchConflict { .. })));
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old installed\n");
}
#[test]
fn install_local_skill_gemini_cli() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("gemini-cli", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".gemini/skills/my-skill/SKILL.md");
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
}
#[test]
fn install_local_skill_codex() {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# My Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target("codex", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let dest = dir.path().join(".codex/skills/my-skill/SKILL.md");
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
}
#[test]
fn codex_skips_agent_entries() {
let dir = tempfile::tempdir().unwrap();
let entry = make_agent_entry("my-agent");
let target = make_target("codex", Scope::Local);
let outcome = install_entry_with_outcome(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
outcome,
InstallOutcome::Skipped(InstallSkipReason::UnsupportedEntity)
);
assert!(!dir.path().join(".codex").exists());
}
#[test]
fn install_github_agent_gemini_cli() {
let dir = tempfile::tempdir().unwrap();
let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
let entry = make_agent_entry("my-agent");
let target = make_target("gemini-cli", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: Some(&InstallOptions::default()),
},
)
.unwrap();
let dest = dir.path().join(".gemini/agents/my-agent.md");
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
}
#[test]
fn install_skill_multi_adapter() {
for adapter in &["claude-code", "gemini-cli", "codex"] {
let dir = tempfile::tempdir().unwrap();
let source_file = dir.path().join("skills/my-skill.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Multi Skill").unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let target = make_target(adapter, Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
let prefix = match *adapter {
"claude-code" => ".claude",
"gemini-cli" => ".gemini",
"codex" => ".codex",
_ => unreachable!(),
};
let dest = dir
.path()
.join(format!("{prefix}/skills/my-skill/SKILL.md"));
assert!(dest.exists(), "Failed for adapter {adapter}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
}
}
#[test]
fn cmd_install_no_manifest() {
let dir = tempfile::tempdir().unwrap();
let result = cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: None,
},
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn cmd_install_no_install_targets() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"local skill foo skills/foo.md\n",
)
.unwrap();
let result = cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: None,
},
);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No install targets"));
}
#[test]
fn cmd_install_extra_targets_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"local skill foo skills/foo.md\n",
)
.unwrap();
let source_file = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Foo").unwrap();
let targets = vec![make_target("claude-code", Scope::Local)];
cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: Some(&targets),
},
)
.unwrap();
let dest = dir.path().join(".claude/skills/foo/SKILL.md");
assert!(
dest.exists(),
"extra_targets must be used when Skillfile has none"
);
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
}
#[test]
fn cmd_install_skillfile_targets_win_over_extra() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"install claude-code local\nlocal skill foo skills/foo.md\n",
)
.unwrap();
let source_file = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Foo").unwrap();
let targets = vec![make_target("gemini-cli", Scope::Local)];
cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: Some(&targets),
},
)
.unwrap();
assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
assert!(!dir.path().join(".gemini").exists());
}
#[test]
fn cmd_install_dry_run_no_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"install claude-code local\nlocal skill foo skills/foo.md\n",
)
.unwrap();
let source_file = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
std::fs::write(&source_file, "# Foo").unwrap();
cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: true,
update: false,
extra_targets: None,
},
)
.unwrap();
assert!(!dir.path().join(".claude").exists());
}
#[test]
fn cmd_install_deploys_to_multiple_adapters() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"install claude-code local\n\
install gemini-cli local\n\
install codex local\n\
local skill foo skills/foo.md\n\
local agent bar agents/bar.md\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("skills")).unwrap();
std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
std::fs::create_dir_all(dir.path().join("agents")).unwrap();
std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: None,
},
)
.unwrap();
assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
assert!(dir.path().join(".gemini/skills/foo/SKILL.md").exists());
assert!(dir.path().join(".codex/skills/foo/SKILL.md").exists());
assert!(dir.path().join(".claude/agents/bar.md").exists());
assert!(dir.path().join(".gemini/agents/bar.md").exists());
assert!(!dir.path().join(".codex/agents").exists());
}
#[test]
fn cmd_install_pending_conflict_blocks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Skillfile"),
"install claude-code local\nlocal skill foo skills/foo.md\n",
)
.unwrap();
write_conflict_fixture(
dir.path(),
&ConflictState {
entry: "foo".into(),
entity_type: EntityType::Skill,
old_sha: "aaa".into(),
new_sha: "bbb".into(),
},
);
let result = cmd_install(
dir.path(),
&CmdInstallOpts {
dry_run: false,
update: false,
extra_targets: None,
},
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pending conflict"));
}
fn make_skill_entry(name: &str) -> Entry {
Entry {
entity_type: EntityType::Skill,
name: name.into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: format!("skills/{name}.md"),
ref_: "main".into(),
},
}
}
fn make_dir_skill_entry(name: &str) -> Entry {
Entry {
entity_type: EntityType::Skill,
name: name.into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: format!("skills/{name}"),
ref_: "main".into(),
},
}
}
fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
std::fs::write(
dir.join("Skillfile"),
format!(
"install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"
),
)
.unwrap();
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
format!("github/skill/{name}"),
LockEntry {
sha: "abc123def456abc123def456abc123def456abc123".into(),
raw_url: format!(
"https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
),
},
);
write_lock_fixture(dir, &locked);
let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
}
#[test]
fn auto_pin_entry_local_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let entry = make_local_entry("my-skill", "skills/my-skill.md");
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
let skills_dir = dir.path().join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
assert!(
!patch_fixture_path(dir.path(), &entry).exists(),
"local entry must never be pinned"
);
}
#[test]
fn auto_pin_entry_missing_lock_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let entry = make_skill_entry("test");
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
assert!(!patch_fixture_path(dir.path(), &entry).exists());
}
#[test]
fn auto_pin_entry_missing_lock_key_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
"github/skill/other".into(),
LockEntry {
sha: "aabbcc".into(),
raw_url: "https://example.com/other.md".into(),
},
);
write_lock_fixture(dir.path(), &locked);
let entry = make_skill_entry("test");
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
assert!(!patch_fixture_path(dir.path(), &entry).exists());
}
#[test]
fn auto_pin_entry_writes_patch_when_installed_differs() {
let dir = tempfile::tempdir().unwrap();
let name = "my-skill";
let cache_content = "# My Skill\n\nOriginal content.\n";
let installed_content = "# My Skill\n\nUser-modified content.\n";
setup_github_skill_repo(dir.path(), name, cache_content);
let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&installed_dir).unwrap();
std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
let entry = make_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
assert!(
patch_fixture_path(dir.path(), &entry).exists(),
"patch should be written when installed differs from cache"
);
std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
installed_content,
);
}
#[test]
fn auto_pin_entry_uses_second_target_when_first_is_clean() {
let dir = tempfile::tempdir().unwrap();
let name = "my-skill";
let cache_content = "# My Skill\n\nOriginal content.\n";
let installed_content = "# My Skill\n\nUser-modified content.\n";
setup_github_skill_repo(dir.path(), name, cache_content);
let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&first_installed_dir).unwrap();
std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
std::fs::create_dir_all(&second_installed_dir).unwrap();
std::fs::write(second_installed_dir.join("SKILL.md"), installed_content).unwrap();
let entry = make_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![
make_target("claude-code", Scope::Local),
make_target("cursor", Scope::Local),
],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
install_entry(
&entry,
&make_target("claude-code", Scope::Local),
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
std::fs::read_to_string(first_installed_dir.join("SKILL.md")).unwrap(),
installed_content,
"auto-pin must preserve edits from a modified secondary target"
);
}
#[test]
fn auto_pin_entry_errors_on_divergent_multi_target_edits() {
let dir = tempfile::tempdir().unwrap();
let name = "my-skill";
let cache_content = "# My Skill\n\nOriginal content.\n";
setup_github_skill_repo(dir.path(), name, cache_content);
let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&first_installed_dir).unwrap();
std::fs::write(
first_installed_dir.join("SKILL.md"),
"# My Skill\n\nClaude edit.\n",
)
.unwrap();
let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
std::fs::create_dir_all(&second_installed_dir).unwrap();
std::fs::write(
second_installed_dir.join("SKILL.md"),
"# My Skill\n\nCursor edit.\n",
)
.unwrap();
let entry = make_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![
make_target("claude-code", Scope::Local),
make_target("cursor", Scope::Local),
],
};
let error = auto_pin_entry(&entry, &manifest, dir.path()).unwrap_err();
assert!(
error
.to_string()
.contains("divergent edits across install targets"),
"unexpected error: {error}"
);
}
#[test]
fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
let dir = tempfile::tempdir().unwrap();
let name = "my-skill";
let cache_content = "# My Skill\n\nOriginal.\n";
let installed_content = "# My Skill\n\nModified.\n";
setup_github_skill_repo(dir.path(), name, cache_content);
let entry = make_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
let patch_text = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+Modified.\n";
write_patch_fixture(dir.path(), &entry, patch_text);
let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&installed_dir).unwrap();
std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
let patch_path = patch_fixture_path(dir.path(), &entry);
let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"patch must not be rewritten when already up to date"
);
}
#[test]
fn auto_pin_entry_repins_when_installed_has_additional_edits() {
let dir = tempfile::tempdir().unwrap();
let name = "my-skill";
let cache_content = "# My Skill\n\nOriginal.\n";
let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
setup_github_skill_repo(dir.path(), name, cache_content);
let entry = make_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
let old_patch = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+First edit.\n";
write_patch_fixture(dir.path(), &entry, old_patch);
let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&installed_dir).unwrap();
std::fs::write(installed_dir.join("SKILL.md"), new_installed).unwrap();
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
let target = make_target("claude-code", Scope::Local);
install_entry(
&entry,
&target,
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
new_installed,
"updated patch must describe the latest installed content"
);
}
#[test]
fn auto_pin_dir_entry_writes_per_file_patches() {
let dir = tempfile::tempdir().unwrap();
let name = "lang-pro";
std::fs::write(
dir.path().join("Skillfile"),
format!(
"install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
),
)
.unwrap();
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
format!("github/skill/{name}"),
LockEntry {
sha: "deadbeefdeadbeefdeadbeef".into(),
raw_url: format!("https://example.com/{name}"),
},
);
write_lock_fixture(dir.path(), &locked);
let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&inst_dir).unwrap();
std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
let entry = make_dir_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
assert!(skill_patch.exists(), "patch for SKILL.md must be written");
let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
assert!(
!examples_patch.exists(),
"patch for examples.md must not be written (content unchanged)"
);
}
#[test]
fn auto_pin_dir_entry_uses_second_target_when_first_is_clean() {
let dir = tempfile::tempdir().unwrap();
let name = "lang-pro";
std::fs::write(
dir.path().join("Skillfile"),
format!(
"install claude-code local\ninstall cursor local\ngithub skill {name} owner/repo skills/{name}\n"
),
)
.unwrap();
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
format!("github/skill/{name}"),
LockEntry {
sha: "deadbeefdeadbeefdeadbeef".into(),
raw_url: format!("https://example.com/{name}"),
},
);
write_lock_fixture(dir.path(), &locked);
let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
let first_inst_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&first_inst_dir).unwrap();
std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
let second_inst_dir = dir.path().join(format!(".cursor/skills/{name}"));
std::fs::create_dir_all(&second_inst_dir).unwrap();
std::fs::write(
second_inst_dir.join("SKILL.md"),
"# Lang Pro\n\nModified.\n",
)
.unwrap();
let entry = make_dir_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![
make_target("claude-code", Scope::Local),
make_target("cursor", Scope::Local),
],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
install_entry(
&entry,
&make_target("claude-code", Scope::Local),
&InstallCtx {
repo_root: dir.path(),
opts: None,
},
)
.unwrap();
assert_eq!(
std::fs::read_to_string(first_inst_dir.join("SKILL.md")).unwrap(),
"# Lang Pro\n\nModified.\n",
"auto-pin must preserve dir-entry edits from a modified secondary target"
);
}
#[test]
fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let name = "lang-pro";
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
format!("github/skill/{name}"),
LockEntry {
sha: "abc".into(),
raw_url: "https://example.com".into(),
},
);
write_lock_fixture(dir.path(), &locked);
let entry = make_dir_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
assert!(!has_dir_patch_fixture(dir.path(), &entry));
}
#[test]
fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
let dir = tempfile::tempdir().unwrap();
let name = "lang-pro";
let cache_content = "# Lang Pro\n\nOriginal.\n";
let modified = "# Lang Pro\n\nModified.\n";
let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
locked.insert(
format!("github/skill/{name}"),
LockEntry {
sha: "abc".into(),
raw_url: "https://example.com".into(),
},
);
write_lock_fixture(dir.path(), &locked);
let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
std::fs::create_dir_all(&inst_dir).unwrap();
std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
let entry = make_dir_skill_entry(name);
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
std::fs::write(&dp, patch_text).unwrap();
let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"dir patch must not be rewritten when already up to date"
);
}
#[test]
fn apply_dir_patches_applies_patch_and_rebases() {
let dir = tempfile::tempdir().unwrap();
let cache_content = "# Skill\n\nOriginal.\n";
let installed_content = "# Skill\n\nModified.\n";
let new_cache_content = "# Skill\n\nOriginal v2.\n";
let expected_rebased_to_new_cache = installed_content;
let entry = make_dir_skill_entry("lang-pro");
let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
std::fs::write(&dp, patch_text).unwrap();
let inst_dir = dir.path().join(".claude/skills/lang-pro");
std::fs::create_dir_all(&inst_dir).unwrap();
std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
std::fs::create_dir_all(&new_cache_dir).unwrap();
std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
let mut installed_files = std::collections::HashMap::new();
installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
apply_dir_patches(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&installed_files,
&new_cache_dir,
)
.unwrap();
let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
assert_eq!(installed_after, installed_content);
std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
let mut reinstall_files = std::collections::HashMap::new();
reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
apply_dir_patches(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&reinstall_files,
&new_cache_dir,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
expected_rebased_to_new_cache,
"rebased patch applied to new_cache must reproduce installed_content"
);
}
#[test]
fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
let dir = tempfile::tempdir().unwrap();
let original = "# Skill\n\nOriginal.\n";
let modified = "# Skill\n\nModified.\n";
let new_cache = modified;
let entry = make_dir_skill_entry("lang-pro");
let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
std::fs::write(&dp, patch_text).unwrap();
let inst_dir = dir.path().join(".claude/skills/lang-pro");
std::fs::create_dir_all(&inst_dir).unwrap();
std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
std::fs::create_dir_all(&new_cache_dir).unwrap();
std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
let mut installed_files = std::collections::HashMap::new();
installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
apply_dir_patches(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&installed_files,
&new_cache_dir,
)
.unwrap();
let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
assert!(
!removed_patch.exists(),
"patch file must be removed when rebase yields empty diff"
);
}
#[test]
fn apply_dir_patches_no_op_when_no_patches_dir() {
let dir = tempfile::tempdir().unwrap();
let entry = make_dir_skill_entry("lang-pro");
let installed_files = std::collections::HashMap::new();
let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
std::fs::create_dir_all(&source_dir).unwrap();
apply_dir_patches(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&installed_files,
&source_dir,
)
.unwrap();
}
#[test]
fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
let dir = tempfile::tempdir().unwrap();
let original = "# Skill\n\nOriginal.\n";
let modified = "# Skill\n\nModified.\n";
let new_cache = modified;
let entry = make_skill_entry("test");
let patch_text =
"--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
write_patch_fixture(dir.path(), &entry, patch_text);
let vdir = dir.path().join(".skillfile/cache/skills/test");
std::fs::create_dir_all(&vdir).unwrap();
let source = vdir.join("test.md");
std::fs::write(&source, new_cache).unwrap();
let installed_dir = dir.path().join(".claude/skills");
std::fs::create_dir_all(&installed_dir).unwrap();
let dest = installed_dir.join("test.md");
std::fs::write(&dest, original).unwrap();
apply_single_file_patch(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&dest,
&source,
)
.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
assert!(
!patch_fixture_path(dir.path(), &entry).exists(),
"patch must be removed when new cache already matches patched content"
);
}
#[test]
fn apply_single_file_patch_rewrites_patch_after_rebase() {
let dir = tempfile::tempdir().unwrap();
let original = "# Skill\n\nOriginal.\n";
let modified = "# Skill\n\nModified.\n";
let new_cache = "# Skill\n\nOriginal v2.\n";
let expected_rebased_result = modified;
let entry = make_skill_entry("test");
let patch_text =
"--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
write_patch_fixture(dir.path(), &entry, patch_text);
let vdir = dir.path().join(".skillfile/cache/skills/test");
std::fs::create_dir_all(&vdir).unwrap();
let source = vdir.join("test.md");
std::fs::write(&source, new_cache).unwrap();
let installed_dir = dir.path().join(".claude/skills");
std::fs::create_dir_all(&installed_dir).unwrap();
let dest = installed_dir.join("test.md");
std::fs::write(&dest, original).unwrap();
apply_single_file_patch(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&dest,
&source,
)
.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
assert!(
patch_fixture_path(dir.path(), &entry).exists(),
"rebased patch must still exist (new_cache != modified)"
);
std::fs::write(&dest, new_cache).unwrap();
std::fs::write(&source, new_cache).unwrap();
apply_single_file_patch(
&PatchCtx {
entry: &entry,
repo_root: dir.path(),
},
&dest,
&source,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
expected_rebased_result,
"rebased patch applied to new_cache must reproduce installed content"
);
}
#[test]
fn check_preconditions_no_targets_returns_error() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![],
install_targets: vec![],
};
let result = check_preconditions(&manifest, dir.path());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No install targets"));
}
#[test]
fn check_preconditions_pending_conflict_returns_error() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
write_conflict_fixture(
dir.path(),
&ConflictState {
entry: "my-skill".into(),
entity_type: EntityType::Skill,
old_sha: "aaa".into(),
new_sha: "bbb".into(),
},
);
let result = check_preconditions(&manifest, dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pending conflict"));
}
#[test]
fn check_preconditions_ok_with_target_and_no_conflict() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![],
install_targets: vec![make_target("claude-code", Scope::Local)],
};
check_preconditions(&manifest, dir.path()).unwrap();
}
}