use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use crate::bail;
use crate::error::{Context, Error, IO, MANIFEST, Result};
use crate::scope::Scope;
use crate::skill::SKILL_MD;
pub const STORE: &str = ".agents/skills";
const BEGIN: &str = "# >>> skillcask managed block, regenerated by skillcask";
const END: &str = "# <<< skillcask managed block";
pub const COPY_MARKER: &str = ".skillcask-copy";
const MODES: &str = ".skillcask-modes";
const TMP: &str = ".skillcask-tmp-";
const OLD: &str = ".skillcask-old-";
const RUN: &str = ".skillcask-run-";
static RUNS: std::sync::Mutex<Vec<(PathBuf, std::fs::File)>> = std::sync::Mutex::new(Vec::new());
pub fn find_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
let root = cwd.ancestors().find(|d| d.join(crate::manifest::FILE).is_file()).unwrap_or(&cwd);
check_root(root)?;
Ok(root.to_path_buf())
}
pub fn check_root(root: &Path) -> Result<()> {
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let Some(r) = canon(root) else { return Ok(()) };
if crate::scope::home().and_then(|h| canon(&h)).as_ref() == Some(&r) {
return Err(Error::new(MANIFEST, "the home directory is the global scope, not a project")
.hint("manage personal skills with --global, e.g. skillcask --global install"));
}
if Scope::global().ok().and_then(|g| canon(&g.config)).as_ref() == Some(&r) {
return Err(Error::new(MANIFEST, format!("{} is the global config directory, not a project", root.display()))
.hint("manage personal skills with --global, e.g. skillcask --global install"));
}
Ok(())
}
pub fn store_dir(s: &Scope, name: &str) -> PathBuf {
s.root.join(STORE).join(name)
}
pub fn store_names(s: &Scope) -> Result<Vec<String>> {
let mut out = Vec::new();
let shown = s.show(STORE);
match std::fs::read_dir(s.root.join(STORE)) {
Ok(rd) => {
for e in rd {
let e = e.ctx(&shown)?;
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with('.') && e.path().join(SKILL_MD).is_file() {
out.push(name);
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).ctx(shown),
}
out.sort();
Ok(out)
}
pub fn no_symlink(s: &Scope, sub: &str) -> Result<()> {
if s.global {
return Ok(());
}
let mut p = s.root.clone();
for c in Path::new(sub).components() {
p.push(c);
if p.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
bail!(
MANIFEST,
"{} is a symlink; skillcask only writes inside the project, replace it with a real file or directory",
rel(&s.root, &p)
);
}
}
Ok(())
}
pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
write_atomic_io(path, text).ctx(path.display())
}
fn write_atomic_io(path: &Path, text: &str) -> std::io::Result<()> {
use std::io::Write;
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
std::fs::create_dir_all(dir)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let mut tmp = tempfile::Builder::new().prefix(&format!(".{name}{TMP}")).tempfile_in(dir)?;
tmp.write_all(text.as_bytes())?;
match std::fs::metadata(&path) {
Ok(m) => tmp.as_file().set_permissions(m.permissions())?,
#[cfg(unix)]
Err(_) => {
use std::os::unix::fs::PermissionsExt;
tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o644))?
}
#[cfg(not(unix))]
Err(_) => {}
}
tmp.persist(&path).map(drop).map_err(|e| e.error)
}
pub fn temp_dir(s: &Scope) -> Result<tempfile::TempDir> {
no_symlink(s, STORE)?;
let store = s.root.join(STORE);
let shown = s.show(STORE);
std::fs::create_dir_all(&store).ctx(&shown)?;
let mut runs = RUNS.lock().unwrap_or_else(|e| e.into_inner());
if !runs.iter().any(|(p, _)| *p == store) {
let name = format!("{RUN}{}", std::process::id());
let f = match std::fs::remove_file(store.join(&name)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => std::fs::File::create_new(store.join(&name)).and_then(|f| f.lock().map(|()| f)),
}
.ctx(format!("{shown}/{name}"))?;
runs.push((store.clone(), f));
}
tempfile::Builder::new().prefix(&format!("{TMP}{}-", std::process::id())).tempdir_in(store).ctx(shown)
}
pub fn place(s: &Scope, name: &str, src: &Path) -> Result<()> {
let staged = temp_dir(s)?;
copy_dir(src, staged.path())?;
let dest = store_dir(s, name);
let old = s.root.join(STORE).join(format!("{OLD}{name}-{}", std::process::id()));
let (shown, old_shown) = (s.show(&format!("{STORE}/{name}")), s.show(&rel(&s.root, &old)));
if old.symlink_metadata().is_ok() {
std::fs::remove_dir_all(&old).ctx(&old_shown)?;
}
if dest.symlink_metadata().is_ok() {
std::fs::rename(&dest, &old).ctx(&shown)?;
}
let staged = staged.keep();
if let Err(e) = std::fs::rename(&staged, &dest) {
if old.symlink_metadata().is_ok() {
let _ = std::fs::rename(&old, &dest);
}
let _ = std::fs::remove_dir_all(&staged);
return Err(e).ctx(shown);
}
if old.symlink_metadata().is_ok() {
std::fs::remove_dir_all(&old).ctx(old_shown)?;
}
Ok(())
}
pub fn clean_leftovers(s: &Scope, keep: &[&Path]) -> Result<()> {
let store = s.root.join(STORE);
let Ok(rd) = std::fs::read_dir(&store) else { return Ok(()) };
let me = std::process::id().to_string();
let gone = |pid: &str| std::fs::File::open(store.join(format!("{RUN}{pid}"))).ok().map(|f| f.try_lock().is_ok());
let mut runs = Vec::new();
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
let p = e.path();
if let Some(pid) = n.strip_prefix(RUN) {
if pid != me && gone(pid) == Some(true) {
runs.push(p);
}
continue;
}
let pid = match (n.strip_prefix(TMP), n.strip_prefix(OLD)) {
(Some(rest), _) => rest.split('-').next(),
(_, Some(rest)) => rest.rsplit('-').next(),
_ => continue,
};
if keep.contains(&p.as_path()) || pid == Some(me.as_str()) {
continue;
}
let stale = || e.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|age| age.as_secs() > 3600));
if pid.and_then(gone).unwrap_or_else(stale) {
let shown = s.show(&rel(&s.root, &p));
if e.file_type().ctx(&shown)?.is_dir() { std::fs::remove_dir_all(p).ctx(shown)? } else { std::fs::remove_file(p).ctx(shown)? }
}
}
for p in runs {
std::fs::remove_file(&p).ctx(s.show(&rel(&s.root, &p)))?;
}
Ok(())
}
pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).ctx(dst.display())?;
for e in std::fs::read_dir(src).ctx(src.display())? {
let e = e.ctx(src.display())?;
let to = dst.join(e.file_name());
if e.file_type().ctx(e.path().display())?.is_dir() {
copy_dir(&e.path(), &to)?;
} else {
std::fs::copy(e.path(), &to).ctx(to.display())?;
}
}
Ok(())
}
pub fn link_target(s: &Scope, at: &str, source: &str, name: &str) -> PathBuf {
if s.global {
let mut p = s.root.clone();
p.extend(source.split('/'));
p.push(name);
return p;
}
let depth = Path::new(at).components().count();
let mut p: PathBuf = std::iter::repeat_n("..", depth).collect();
p.extend(source.split('/'));
p.push(name);
p
}
#[derive(Debug, PartialEq)]
pub enum LinkState {
Ok,
Missing,
Wrong,
Foreign,
}
fn is_store_itself(s: &Scope, at: &str, name: &str) -> bool {
let path = s.root.join(at).join(name);
s.global
&& path.symlink_metadata().is_ok_and(|m| !m.file_type().is_symlink())
&& matches!((std::fs::canonicalize(&path), std::fs::canonicalize(store_dir(s, name))), (Ok(a), Ok(b)) if a == b)
}
pub fn link_state(s: &Scope, at: &str, source: &str, name: &str) -> LinkState {
let path = s.root.join(at).join(name);
let Ok(meta) = path.symlink_metadata() else { return LinkState::Missing };
if at != STORE && is_store_itself(s, at, name) {
return LinkState::Ok;
}
if meta.file_type().is_symlink() {
let to_store = if s.global {
matches!((std::fs::canonicalize(&path), std::fs::canonicalize(s.root.join(source).join(name))), (Ok(a), Ok(b)) if a == b)
} else {
std::fs::read_link(&path).is_ok_and(|t| t == link_target(s, at, source, name))
};
let ok = to_store && path.join(SKILL_MD).is_file();
return if ok { LinkState::Ok } else { LinkState::Wrong };
}
if meta.is_dir() && path.join(COPY_MARKER).is_file() {
let hash = |p: &Path| {
crate::skill::scan_dir(p).ok().map(|mut e| {
e.retain(|f| f.path != COPY_MARKER);
crate::skill::tree_hash(&e)
})
};
let fresh = hash(&s.root.join(source).join(name));
return if fresh.is_some() && fresh == hash(&path) { LinkState::Ok } else { LinkState::Wrong };
}
LinkState::Foreign
}
pub fn points_into(s: &Scope, path: &Path, dir: &str) -> bool {
let base = s.root.join(dir);
if let (Ok(a), Ok(b)) = (std::fs::canonicalize(path), std::fs::canonicalize(&base))
&& a.starts_with(b)
{
return true;
}
let Ok(target) = std::fs::read_link(path) else { return false };
let mut p = path.parent().map(Path::to_path_buf).unwrap_or_default();
for c in target.components() {
match c {
Component::ParentDir => {
p.pop();
}
Component::CurDir => {}
c => p.push(c),
}
}
p.starts_with(base)
}
fn force_copy() -> bool {
std::env::var("SKILLCASK_LINK_MODE").is_ok_and(|v| v == "copy")
}
pub fn link(s: &Scope, at: &str, source: &str, name: &str) -> Result<bool> {
let path = s.root.join(at).join(name);
match link_state(s, at, source, name) {
LinkState::Ok => return Ok(!is_store_itself(s, at, name) && path.symlink_metadata().is_ok_and(|m| !m.file_type().is_symlink())),
LinkState::Foreign => bail!(MANIFEST, "{} exists and was not created by skillcask; move it away", s.show(&format!("{at}/{name}"))),
LinkState::Wrong => unlink(s, at, name)?,
LinkState::Missing => {}
}
std::fs::create_dir_all(s.root.join(at)).ctx(s.show(at))?;
let shown = s.show(&format!("{at}/{name}"));
if !force_copy() {
match symlink(&link_target(s, at, source, name), &path) {
Ok(()) => return Ok(false),
Err(e) if !cfg!(windows) => return Err(e).ctx(shown),
Err(_) => {}
}
}
copy_dir(&s.root.join(source).join(name), &path)?;
std::fs::write(path.join(COPY_MARKER), "").ctx(format!("{shown}/{COPY_MARKER}"))?;
Ok(true)
}
pub fn local_names(s: &Scope, local: &str) -> Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
if s.global {
return Ok(out);
}
let Ok(rd) = std::fs::read_dir(s.root.join(local)) else { return Ok(out) };
for e in rd {
let e = e.ctx(s.show(local))?;
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !e.path().join(SKILL_MD).is_file() {
continue;
}
if !crate::skill::valid_name(&name) {
bail!(MANIFEST, "{local}/{name}: skill directory names must match [a-z0-9][a-z0-9-]*");
}
out.insert(name);
}
Ok(out)
}
pub fn stale_local_links(s: &Scope, local: &str, live: &BTreeSet<String>) -> Result<Vec<String>> {
if s.global {
return Ok(Vec::new());
}
let block = read_block(s)?;
let Ok(rd) = std::fs::read_dir(s.root.join(STORE)) else { return Ok(Vec::new()) };
Ok(rd
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| !live.contains(n) && made_by_skillcask(s, STORE, local, n, &block))
.collect())
}
pub fn made_by_skillcask(s: &Scope, at: &str, source: &str, name: &str, block: &[String]) -> bool {
let path = s.root.join(at).join(name);
std::fs::read_link(&path).is_ok_and(|t| t == link_target(s, at, source, name))
|| (block.contains(&format!("/{at}/{name}")) && path.join(COPY_MARKER).is_file())
}
#[cfg(unix)]
fn symlink(target: &Path, at: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, at)
}
#[cfg(windows)]
fn symlink(target: &Path, at: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, at)
}
pub fn unlink(s: &Scope, at: &str, name: &str) -> Result<()> {
let path = s.root.join(at).join(name);
let Ok(meta) = path.symlink_metadata() else { return Ok(()) };
if at != STORE && is_store_itself(s, at, name) {
return Ok(());
}
let shown = s.show(&format!("{at}/{name}"));
if meta.file_type().is_symlink() {
std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path)).ctx(shown)?;
} else if meta.is_dir() && path.join(COPY_MARKER).is_file() {
std::fs::remove_dir_all(&path).ctx(shown)?;
}
Ok(())
}
pub fn rel(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
}
pub fn block_lines(managed: &BTreeSet<String>, targets: &[String], copies: &BTreeSet<String>) -> Vec<String> {
let mut lines = BTreeSet::new();
for n in managed {
lines.insert(format!("/{STORE}/{n}/"));
for t in targets {
lines.insert(format!("/{t}/{n}"));
}
}
lines.extend(copies.iter().cloned());
if !lines.is_empty() {
lines.insert(format!("/{STORE}/.skillcask-*"));
lines.insert(format!("/.*{TMP}*"));
}
lines.into_iter().collect()
}
pub fn read_modes(s: &Scope) -> BTreeSet<String> {
std::fs::read_to_string(s.root.join(STORE).join(MODES)).unwrap_or_default().lines().map(String::from).collect()
}
pub fn write_modes(s: &Scope, modes: &BTreeSet<String>) -> Result<()> {
if cfg!(unix) {
return Ok(());
}
let path = s.root.join(STORE).join(MODES);
let shown = s.show(&format!("{STORE}/{MODES}"));
if modes.is_empty() {
if path.exists() {
std::fs::remove_file(path).ctx(shown)?;
}
return Ok(());
}
let text: String = modes.iter().map(|l| format!("{l}\n")).collect();
if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
std::fs::write(path, text).ctx(shown)?;
}
Ok(())
}
struct Gitignore {
text: String,
eol: &'static str,
kept: Vec<String>,
at: Option<usize>,
block: Vec<String>,
}
const INSTALLED: &str = ".skillcask-installed";
fn ignore_file(s: &Scope) -> PathBuf {
if s.global { s.root.join(STORE).join(INSTALLED) } else { s.root.join(".gitignore") }
}
pub fn block_file(s: &Scope) -> String {
if s.global { s.show(&format!("{STORE}/{INSTALLED}")) } else { ".gitignore".into() }
}
fn record_file(s: &Scope) -> (PathBuf, String) {
(s.root.join(STORE).join(INSTALLED), s.show(&format!("{STORE}/{INSTALLED}")))
}
fn read_gitignore(s: &Scope) -> Result<Gitignore> {
read_block_file(&ignore_file(s), &block_file(s))
}
fn read_block_file(path: &Path, shown: &str) -> Result<Gitignore> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => bail!(IO, "{shown}: {e}; skillcask only rewrites a readable UTF-8 file"),
};
let eol = if text.split_once('\n').is_some_and(|(l, _)| l.ends_with('\r')) { "\r\n" } else { "\n" };
let (mut kept, mut block) = (Vec::new(), Vec::new());
let mut inside = false;
let mut at = None;
for l in text.lines() {
match l.trim_end() {
BEGIN => {
inside = true;
at.get_or_insert(kept.len());
}
END if inside => inside = false,
b if inside => {
if !b.is_empty() {
block.push(b.to_string());
}
}
_ => kept.push(l.to_string()),
}
}
if inside {
bail!(MANIFEST, "{shown}: the skillcask block has no end line {END:?}; restore it by hand");
}
Ok(Gitignore { text, eol, kept, at, block })
}
pub fn read_block(s: &Scope) -> Result<Vec<String>> {
Ok(read_gitignore(s)?.block)
}
pub fn block_managed(s: &Scope) -> Result<BTreeSet<String>> {
Ok(store_lines(&read_block(s)?))
}
fn store_lines(block: &[String]) -> BTreeSet<String> {
let prefix = format!("/{STORE}/");
block
.iter()
.filter_map(|l| l.strip_prefix(&prefix)?.strip_suffix('/'))
.filter(|n| crate::skill::valid_name(n))
.map(String::from)
.collect()
}
pub fn installed_here(s: &Scope) -> Result<Option<BTreeSet<String>>> {
if s.global {
return block_managed(s).map(Some);
}
let (path, shown) = record_file(s);
if path.symlink_metadata().is_err() {
return Ok(None);
}
Ok(Some(store_lines(&read_block_file(&path, &shown)?.block)))
}
pub fn lock_history_hashes(s: &Scope) -> BTreeSet<String> {
let o = match git(s).args(["log", "-p", "--format=", "--", crate::lock::FILE]).output() {
Ok(o) if o.status.success() => o,
_ => return BTreeSet::new(),
};
String::from_utf8_lossy(&o.stdout)
.split("tree-sha256 = \"")
.skip(1)
.filter_map(|r| r.get(..64).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()) && r[64..].starts_with('"')))
.map(String::from)
.collect()
}
pub fn write_installed_here(s: &Scope, names: &BTreeSet<String>) -> Result<()> {
if s.global {
return Ok(());
}
no_symlink(s, STORE)?;
let (path, shown) = record_file(s);
let lines: Vec<String> = names.iter().map(|n| format!("/{STORE}/{n}/")).collect();
write_block_file(&path, &shown, &lines)?;
Ok(())
}
pub fn write_block(s: &Scope, lines: &[String]) -> Result<bool> {
no_symlink(s, ".gitignore")?;
write_block_file(&ignore_file(s), &block_file(s), lines)
}
fn write_block_file(path: &Path, shown: &str, lines: &[String]) -> Result<bool> {
let Gitignore { text, eol, mut kept, at, .. } = read_block_file(path, shown)?;
let mut block = Vec::new();
if !lines.is_empty() {
block.push(BEGIN.to_string());
block.extend(lines.iter().cloned());
block.push(END.to_string());
}
let at = match at {
Some(i) => i,
None => {
if !block.is_empty() && kept.last().is_some_and(|l| !l.is_empty()) {
kept.push(String::new());
}
kept.len()
}
};
kept.splice(at..at, block);
while kept.last().is_some_and(String::is_empty) {
kept.pop();
}
let new = if kept.is_empty() { String::new() } else { kept.join(eol) + eol };
if new == text || (new.is_empty() && !path.exists()) {
return Ok(false);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ctx(shown)?;
}
std::fs::write(path, new).ctx(shown)?;
Ok(true)
}
fn git(s: &Scope) -> Command {
let mut c = Command::new("git");
c.arg("-C").arg(&s.root).arg("--literal-pathspecs");
c.env_remove("GIT_DIR").env_remove("GIT_INDEX_FILE").env_remove("GIT_WORK_TREE");
c
}
pub fn git_tracked(s: &Scope, paths: &[String]) -> Vec<String> {
if s.global || paths.is_empty() {
return Vec::new();
}
let out = git(s).args(["ls-files", "-z", "--"]).args(paths).output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).split('\0').filter(|s| !s.is_empty()).map(String::from).collect(),
_ => Vec::new(),
}
}
pub fn git_untrack(s: &Scope, paths: &[String]) -> Result<()> {
if s.global || paths.is_empty() {
return Ok(());
}
let st = git(s).args(["rm", "--cached", "-f", "-r", "-q", "--"]).args(paths).status()?;
if !st.success() {
bail!(IO, "git rm --cached failed for {}", paths.join(", "));
}
Ok(())
}
const NOT_PROJECTS: [&str; 4] = ["node_modules", "vendor", "target", "Library"];
pub fn find_projects(dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
if dir.join(crate::manifest::FILE).is_file() {
found.push(dir.to_path_buf());
}
let Some(rd) = std::fs::read_dir(dir).ok().filter(|_| depth > 0) else { return };
for e in rd.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !NOT_PROJECTS.contains(&name.as_ref()) {
find_projects(&e.path(), depth - 1, found);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_link_target() {
let s = Scope::project(PathBuf::new());
assert_eq!(link_target(&s, ".claude/skills", STORE, "x"), Path::new("../../.agents/skills/x"));
assert_eq!(link_target(&s, "skills", STORE, "x"), Path::new("../.agents/skills/x"));
assert_eq!(link_target(&s, STORE, ".skills", "x"), Path::new("../../.skills/x"));
}
#[test]
fn gitignore_block_rewrite() {
let dir = tempfile::tempdir().unwrap();
let s = Scope::project(dir.path().to_path_buf());
let root = &s.root;
std::fs::write(root.join(".gitignore"), "/target\n").unwrap();
let managed: BTreeSet<String> = ["b".to_string(), "a".to_string()].into();
let lines = block_lines(&managed, &[".claude/skills".into()], &BTreeSet::new());
assert!(write_block(&s, &lines).unwrap());
let text = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(
text,
format!(
"/target\n\n{BEGIN}\n/.*.skillcask-tmp-*\n/.agents/skills/.skillcask-*\n/.agents/skills/a/\n/.agents/skills/b/\n/.claude/skills/a\n/.claude/skills/b\n{END}\n"
)
);
assert!(!write_block(&s, &lines).unwrap());
assert_eq!(block_managed(&s).unwrap(), managed);
std::fs::write(root.join(".gitignore"), format!("{BEGIN}\n/.agents/skills/../../home/\n/.agents/skills/ok/\n{END}\n")).unwrap();
assert_eq!(block_managed(&s).unwrap(), BTreeSet::from(["ok".to_string()]), "traversal in the editable block is ignored");
std::fs::write(root.join(".gitignore"), &text).unwrap();
std::fs::write(root.join(".gitignore"), format!("{text}/after\n")).unwrap();
write_block(&s, &lines[2..3]).unwrap();
let text = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(text, format!("/target\n\n{BEGIN}\n/.agents/skills/a/\n{END}\n/after\n"));
write_block(&s, &[]).unwrap();
assert_eq!(std::fs::read_to_string(root.join(".gitignore")).unwrap(), "/target\n\n/after\n");
}
#[test]
fn place_survives_a_stale_old_dir() {
let dir = tempfile::tempdir().unwrap();
let s = Scope::project(dir.path().to_path_buf());
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join(SKILL_MD), "new").unwrap();
std::fs::create_dir_all(store_dir(&s, "x")).unwrap();
let stale = s.root.join(STORE).join(format!("{OLD}x-{}", std::process::id()));
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("f"), "").unwrap();
place(&s, "x", &src).unwrap();
assert_eq!(std::fs::read_to_string(store_dir(&s, "x").join(SKILL_MD)).unwrap(), "new");
assert!(!stale.exists());
}
#[cfg(unix)]
#[test]
fn links_and_refuses_foreign() {
let dir = tempfile::tempdir().unwrap();
let s = Scope::project(dir.path().to_path_buf());
let root = &s.root;
std::fs::create_dir_all(store_dir(&s, "x")).unwrap();
std::fs::write(store_dir(&s, "x").join(SKILL_MD), "s").unwrap();
assert_eq!(link_state(&s, ".claude/skills", STORE, "x"), LinkState::Missing);
assert!(!link(&s, ".claude/skills", STORE, "x").unwrap());
assert_eq!(link_state(&s, ".claude/skills", STORE, "x"), LinkState::Ok);
assert_eq!(std::fs::read_to_string(root.join(".claude/skills/x/SKILL.md")).unwrap(), "s");
std::fs::create_dir_all(root.join(".junie/skills/x")).unwrap();
assert!(link(&s, ".junie/skills", STORE, "x").is_err());
unlink(&s, ".claude/skills", "x").unwrap();
assert_eq!(link_state(&s, ".claude/skills", STORE, "x"), LinkState::Missing);
}
#[cfg(unix)]
#[test]
fn a_planted_run_lock_symlink_leaves_its_target_alone() {
let dir = tempfile::tempdir().unwrap();
let s = Scope::project(dir.path().to_path_buf());
let victim = dir.path().join("victim");
std::fs::write(&victim, "precious").unwrap();
std::fs::create_dir_all(dir.path().join(STORE)).unwrap();
std::os::unix::fs::symlink(&victim, dir.path().join(STORE).join(format!("{RUN}{}", std::process::id()))).unwrap();
temp_dir(&s).unwrap();
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "precious");
}
fn global_scope(home: &Path) -> Scope {
Scope { root: home.to_path_buf(), config: home.join(".config/skillcask"), global: true }
}
#[cfg(unix)]
#[test]
fn global_links_are_absolute_and_survive_symlinked_parents() {
let home = tempfile::tempdir().unwrap();
let s = global_scope(home.path());
std::fs::create_dir_all(store_dir(&s, "x")).unwrap();
std::fs::write(store_dir(&s, "x").join(SKILL_MD), "s").unwrap();
let dotfiles = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(dotfiles.path(), home.path().join(".claude")).unwrap();
no_symlink(&s, ".claude/skills").unwrap();
assert!(!link(&s, ".claude/skills", STORE, "x").unwrap());
assert_eq!(std::fs::read_link(home.path().join(".claude/skills/x")).unwrap(), home.path().join(".agents/skills/x"));
assert_eq!(link_state(&s, ".claude/skills", STORE, "x"), LinkState::Ok);
assert_eq!(std::fs::read_to_string(dotfiles.path().join("skills/x/SKILL.md")).unwrap(), "s");
std::fs::create_dir_all(home.path().join(".junie/skills")).unwrap();
std::os::unix::fs::symlink("../../.agents/skills/x", home.path().join(".junie/skills/x")).unwrap();
assert_eq!(link_state(&s, ".junie/skills", STORE, "x"), LinkState::Ok);
}
#[cfg(unix)]
#[test]
fn global_target_symlinked_to_the_store_is_the_store() {
let home = tempfile::tempdir().unwrap();
let s = global_scope(home.path());
std::fs::create_dir_all(store_dir(&s, "x")).unwrap();
std::fs::write(store_dir(&s, "x").join(SKILL_MD), "s").unwrap();
std::fs::create_dir_all(home.path().join(".claude")).unwrap();
std::os::unix::fs::symlink("../.agents/skills", home.path().join(".claude/skills")).unwrap();
assert_eq!(link_state(&s, ".claude/skills", STORE, "x"), LinkState::Ok);
assert!(!link(&s, ".claude/skills", STORE, "x").unwrap());
unlink(&s, ".claude/skills", "x").unwrap();
assert!(store_dir(&s, "x").join(SKILL_MD).is_file());
std::fs::write(store_dir(&s, "x").join(COPY_MARKER), "").unwrap();
unlink(&s, ".claude/skills", "x").unwrap();
assert!(store_dir(&s, "x").join(SKILL_MD).is_file());
}
#[test]
fn global_block_lives_in_the_store() {
let home = tempfile::tempdir().unwrap();
let s = global_scope(home.path());
let managed: BTreeSet<String> = ["a".to_string()].into();
assert!(write_block(&s, &block_lines(&managed, &[".claude/skills".into()], &BTreeSet::new())).unwrap());
assert!(!home.path().join(".gitignore").exists());
assert!(!s.config.exists(), "the config dir may be synced between machines; the managed list is machine-local");
assert!(std::fs::read_to_string(home.path().join(".agents/skills/.skillcask-installed")).unwrap().contains("/.agents/skills/a/"));
assert_eq!(block_managed(&s).unwrap(), managed);
assert_eq!(store_names(&s).unwrap(), Vec::<String>::new());
std::fs::write(ignore_file(&s), format!("{BEGIN}\n/.agents/skills/a/\n")).unwrap();
let e = block_managed(&s).unwrap_err().to_string();
assert!(e.contains("~/.agents/skills/.skillcask-installed: the skillcask block has no end line"), "{e}");
assert!(git_tracked(&s, &["x".into()]).is_empty());
std::fs::create_dir_all(home.path().join("p")).unwrap();
std::fs::write(home.path().join("p/SKILL.md"), "").unwrap();
assert!(local_names(&s, "").unwrap().is_empty(), "the global scope has no project skills");
}
}