use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::bail;
use crate::error::{IO, MANIFEST, Result};
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";
const COPY_MARKER: &str = ".skillcask-copy";
const MODES: &str = ".skillcask-modes";
pub fn find_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
Ok(cwd.ancestors().find(|d| d.join(crate::manifest::FILE).is_file()).unwrap_or(&cwd).to_path_buf())
}
pub fn store_dir(root: &Path, name: &str) -> PathBuf {
root.join(STORE).join(name)
}
pub fn store_names(root: &Path) -> Result<Vec<String>> {
let mut out = Vec::new();
match std::fs::read_dir(root.join(STORE)) {
Ok(rd) => {
for e in rd {
let e = e?;
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.into()),
}
out.sort();
Ok(out)
}
pub fn no_symlink(root: &Path, sub: &str) -> Result<()> {
let mut p = root.to_path_buf();
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(root, &p)
);
}
}
Ok(())
}
pub fn temp_dir(root: &Path) -> Result<tempfile::TempDir> {
no_symlink(root, STORE)?;
let store = root.join(STORE);
std::fs::create_dir_all(&store)?;
Ok(tempfile::Builder::new().prefix(".skillcask-tmp-").tempdir_in(store)?)
}
pub fn place(root: &Path, name: &str, src: &Path) -> Result<()> {
let staged = temp_dir(root)?;
copy_dir(src, staged.path())?;
let dest = store_dir(root, name);
let old = root.join(STORE).join(format!(".skillcask-old-{name}-{}", std::process::id()));
if dest.exists() {
std::fs::rename(&dest, &old)?;
}
let staged = staged.keep();
if let Err(e) = std::fs::rename(&staged, &dest) {
if old.exists() {
let _ = std::fs::rename(&old, &dest);
}
let _ = std::fs::remove_dir_all(&staged);
return Err(e.into());
}
if old.exists() {
std::fs::remove_dir_all(&old)?;
}
Ok(())
}
pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for e in std::fs::read_dir(src)? {
let e = e?;
let to = dst.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &to)?;
} else {
std::fs::copy(e.path(), &to)?;
}
}
Ok(())
}
pub fn link_target(at: &str, source: &str, name: &str) -> PathBuf {
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,
}
pub fn link_state(root: &Path, at: &str, source: &str, name: &str) -> LinkState {
let path = root.join(at).join(name);
let Ok(meta) = path.symlink_metadata() else { return LinkState::Missing };
if meta.file_type().is_symlink() {
let ok = std::fs::read_link(&path).is_ok_and(|t| t == link_target(at, source, name)) && 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(&root.join(source).join(name));
return if fresh.is_some() && fresh == hash(&path) { LinkState::Ok } else { LinkState::Wrong };
}
LinkState::Foreign
}
fn force_copy() -> bool {
std::env::var("SKILLCASK_LINK_MODE").is_ok_and(|v| v == "copy")
}
pub fn link(root: &Path, at: &str, source: &str, name: &str) -> Result<bool> {
let path = root.join(at).join(name);
match link_state(root, at, source, name) {
LinkState::Ok => return Ok(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", rel(root, &path)),
LinkState::Wrong => unlink(root, at, name)?,
LinkState::Missing => {}
}
std::fs::create_dir_all(root.join(at))?;
if !force_copy() {
match symlink(&link_target(at, source, name), &path) {
Ok(()) => return Ok(false),
Err(e) if !cfg!(windows) => return Err(e.into()),
Err(_) => {}
}
}
copy_dir(&root.join(source).join(name), &path)?;
std::fs::write(path.join(COPY_MARKER), "")?;
Ok(true)
}
pub fn local_names(root: &Path, local: &str) -> Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
let Ok(rd) = std::fs::read_dir(root.join(local)) else { return Ok(out) };
for e in rd {
let e = e?;
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(root: &Path, local: &str, live: &BTreeSet<String>) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(root.join(STORE)) else { return Vec::new() };
rd.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let is_ours =
std::fs::read_link(e.path()).is_ok_and(|t| t == link_target(STORE, local, &name)) || e.path().join(COPY_MARKER).is_file();
(is_ours && !live.contains(&name)).then_some(name)
})
.collect()
}
#[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(root: &Path, at: &str, name: &str) -> Result<()> {
let path = root.join(at).join(name);
let Ok(meta) = path.symlink_metadata() else { return Ok(()) };
if meta.file_type().is_symlink() {
std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path))?;
} else if meta.is_dir() && path.join(COPY_MARKER).is_file() {
std::fs::remove_dir_all(&path)?;
}
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();
if !managed.is_empty() {
lines.insert(format!("/{STORE}/{MODES}"));
}
for n in managed {
lines.insert(format!("/{STORE}/{n}/"));
for t in targets {
lines.insert(format!("/{t}/{n}"));
}
}
lines.extend(copies.iter().cloned());
lines.into_iter().collect()
}
pub fn read_modes(root: &Path) -> BTreeSet<String> {
std::fs::read_to_string(root.join(STORE).join(MODES)).unwrap_or_default().lines().map(String::from).collect()
}
pub fn write_modes(root: &Path, modes: &BTreeSet<String>) -> Result<()> {
if cfg!(unix) {
return Ok(());
}
let path = root.join(STORE).join(MODES);
if modes.is_empty() {
if path.exists() {
std::fs::remove_file(path)?;
}
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)?;
}
Ok(())
}
struct Gitignore {
text: String,
kept: Vec<String>,
at: Option<usize>,
block: Vec<String>,
}
fn read_gitignore(root: &Path) -> Result<Gitignore> {
let text = std::fs::read_to_string(root.join(".gitignore")).unwrap_or_default();
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, ".gitignore: the skillcask block has no end line {END:?}; restore it by hand");
}
Ok(Gitignore { text, kept, at, block })
}
pub fn read_block(root: &Path) -> Result<Vec<String>> {
Ok(read_gitignore(root)?.block)
}
pub fn block_managed(root: &Path) -> Result<BTreeSet<String>> {
let prefix = format!("/{STORE}/");
Ok(read_block(root)?
.iter()
.filter_map(|l| l.strip_prefix(&prefix)?.strip_suffix('/'))
.filter(|n| crate::skill::valid_name(n))
.map(String::from)
.collect())
}
pub fn write_block(root: &Path, lines: &[String]) -> Result<bool> {
no_symlink(root, ".gitignore")?;
let path = root.join(".gitignore");
let Gitignore { text, mut kept, at, .. } = read_gitignore(root)?;
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("\n") + "\n" };
if new == text || (new.is_empty() && !path.exists()) {
return Ok(false);
}
std::fs::write(&path, new)?;
Ok(true)
}
pub fn git_tracked(root: &Path, paths: &[String]) -> Vec<String> {
if paths.is_empty() {
return Vec::new();
}
let out = Command::new("git").arg("-C").arg(root).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(root: &Path, paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Ok(());
}
let st = Command::new("git").arg("-C").arg(root).args(["rm", "--cached", "-r", "-q", "--"]).args(paths).status()?;
if !st.success() {
bail!(IO, "git rm --cached failed for {}", paths.join(", "));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_link_target() {
assert_eq!(link_target(".claude/skills", STORE, "x"), Path::new("../../.agents/skills/x"));
assert_eq!(link_target("skills", STORE, "x"), Path::new("../.agents/skills/x"));
assert_eq!(link_target(STORE, ".skills", "x"), Path::new("../../.skills/x"));
}
#[test]
fn gitignore_block_rewrite() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
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(root, &lines).unwrap());
let text = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(
text,
format!(
"/target\n\n{BEGIN}\n/.agents/skills/.skillcask-modes\n/.agents/skills/a/\n/.agents/skills/b/\n/.claude/skills/a\n/.claude/skills/b\n{END}\n"
)
);
assert!(!write_block(root, &lines).unwrap());
assert_eq!(block_managed(root).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(root).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(root, &lines[1..2]).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(root, &[]).unwrap();
assert_eq!(std::fs::read_to_string(root.join(".gitignore")).unwrap(), "/target\n\n/after\n");
}
#[cfg(unix)]
#[test]
fn links_and_refuses_foreign() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(store_dir(root, "x")).unwrap();
std::fs::write(store_dir(root, "x").join(SKILL_MD), "s").unwrap();
assert_eq!(link_state(root, ".claude/skills", STORE, "x"), LinkState::Missing);
assert!(!link(root, ".claude/skills", STORE, "x").unwrap());
assert_eq!(link_state(root, ".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(root, ".junie/skills", STORE, "x").is_err());
unlink(root, ".claude/skills", "x").unwrap();
assert_eq!(link_state(root, ".claude/skills", STORE, "x"), LinkState::Missing);
}
}