use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use tempfile::TempDir;
use crate::git::Git;
use crate::harness::Adapter;
mod directory;
use directory::CheckedDirectory;
#[cfg(test)]
mod tests;
pub static SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/skills");
pub struct Skill {
pub name: String,
pub path: PathBuf,
}
enum Source {
Factory,
Repository(PathBuf),
Home(PathBuf),
}
pub fn find(worktree: &Path, name: &str) -> Result<Skill> {
let mut available = BTreeMap::new();
for dir in SKILLS.dirs() {
available.insert(dir.path().to_string_lossy().into_owned(), Source::Factory);
}
for (skill_name, path) in repository_skills(worktree)? {
available
.entry(skill_name)
.or_insert(Source::Repository(path));
}
for (skill_name, path) in home_skills()? {
available.entry(skill_name).or_insert(Source::Home(path));
}
let selected = available.get_key_value(name).or_else(|| {
available
.get_key_value(&format!("thirdshift-{name}"))
.filter(|(_, source)| matches!(source, Source::Factory))
});
if let Some((name, source)) = selected {
return Ok(Skill {
path: match source {
Source::Factory => written_out()?.join(name).join("SKILL.md"),
Source::Repository(path) | Source::Home(path) => path.clone(),
},
name: name.clone(),
});
}
bail!(
"unknown skill {name:?}; available skills: {}",
available.keys().cloned().collect::<Vec<_>>().join(", ")
)
}
fn resolved_inside(root: &Path, candidate: &Path) -> bool {
fs::canonicalize(candidate).is_ok_and(|target| target.starts_with(root))
}
fn repository_skills(worktree: &Path) -> Result<Vec<(String, PathBuf)>> {
let root = fs::canonicalize(worktree)
.with_context(|| format!("could not resolve {}", worktree.display()))?;
let mut skills = Vec::new();
for folder in [".agents/skills", ".claude/skills"] {
let folder_path = worktree.join(folder);
match fs::symlink_metadata(&folder_path) {
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => continue,
Err(error) => return Err(error).with_context(|| format!("could not list {folder}")),
}
if !resolved_inside(&root, &folder_path) {
continue;
}
let entries =
fs::read_dir(&folder_path).with_context(|| format!("could not list {folder}"))?;
for entry in entries {
let entry = entry?;
let path = entry.path().join("SKILL.md");
if !resolved_inside(&root, &path) || !path.is_file() {
continue;
}
let name = entry.file_name().into_string().map_err(|_| {
anyhow::anyhow!(
"skill directory name is not UTF-8: {}",
entry.path().display()
)
})?;
skills.push((name, path));
}
}
Ok(skills)
}
struct HomeSkill {
name: String,
skill_md: PathBuf,
}
struct HomeCatalog {
root: PathBuf,
skills: Vec<HomeSkill>,
}
fn home_skills() -> Result<Vec<(String, PathBuf)>> {
Ok(read_home_skills()?
.map(|catalog| {
catalog
.skills
.into_iter()
.map(|skill| (skill.name, skill.skill_md))
.collect()
})
.unwrap_or_default())
}
fn read_home_skills() -> Result<Option<HomeCatalog>> {
let Ok(home) = crate::config::home() else {
return Ok(None);
};
let skills_dir = home.join(".thirdshift/skills");
let entries = match fs::read_dir(&skills_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("could not list {}", skills_dir.display()));
}
};
let root = match fs::canonicalize(&skills_dir) {
Ok(root) => root,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error)
.with_context(|| format!("could not resolve {}", skills_dir.display()));
}
};
let mut skills = Vec::new();
for entry in entries {
let entry = entry.with_context(|| format!("could not list {}", skills_dir.display()))?;
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if !usable_home_skill_name(&name) {
continue;
}
let path = entry.path().join("SKILL.md");
if !resolved_inside(&root, &path) || !path.is_file() {
continue;
}
skills.push(HomeSkill {
name,
skill_md: path,
});
}
Ok(Some(HomeCatalog { root, skills }))
}
fn usable_home_skill_name(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& !name
.chars()
.any(|ch| ch.is_control() || matches!(ch, '/' | '\\'))
}
fn exclude_literal(path: &str) -> String {
let chars: Vec<char> = path.chars().collect();
let last_non_space = chars.iter().rposition(|ch| *ch != ' ').unwrap_or(0);
let mut escaped = String::new();
for (index, ch) in chars.into_iter().enumerate() {
if matches!(ch, '*' | '?' | '[' | ']' | '\\') || (ch == ' ' && index > last_non_space) {
escaped.push('\\');
}
escaped.push(ch);
}
escaped
}
static WRITTEN: Mutex<Option<TempDir>> = Mutex::new(None);
pub fn link_into(worktree: &Path, adapter: &dyn Adapter) -> Result<()> {
let dir = adapter.project_skills();
let project_skills = CheckedDirectory::check(worktree, Path::new(dir))?;
let written = written_out()?;
exclude(&Git::new(worktree), &format!("/{dir}/thirdshift-*"))?;
let project_skills = project_skills.create_missing()?;
for skill in SKILLS.dirs() {
let name = skill.path();
project_skills.link(&written.join(name), name)?;
}
link_home_skills(worktree, &project_skills, dir)?;
Ok(())
}
fn link_home_skills(worktree: &Path, project_skills: &CheckedDirectory, dir: &str) -> Result<()> {
let Some(catalog) = read_home_skills()? else {
return Ok(());
};
if catalog.skills.is_empty() {
return Ok(());
}
let root = fs::canonicalize(worktree)
.with_context(|| format!("could not resolve {}", worktree.display()))?;
for skill in catalog.skills {
if factory_skill(&skill.name) || repository_has_skill(&root, worktree, &skill.name) {
continue;
}
let Some(home_directory) = skill.skill_md.parent() else {
continue;
};
let destination = worktree.join(dir).join(&skill.name);
let published = written_out()?.join(&skill.name);
if !home_link_allowed(&destination, &published, home_directory)? {
continue;
}
publish_home_skill(&catalog.root, &skill.name, home_directory, &published)?;
exclude(
&Git::new(worktree),
&exclude_literal(&format!("/{dir}/{}", skill.name)),
)?;
project_skills.link(&published, Path::new(&skill.name))?;
}
Ok(())
}
fn factory_skill(name: &str) -> bool {
SKILLS.dirs().any(|dir| dir.path().to_str() == Some(name))
}
static HOME_SKILL_PUBLICATION: Mutex<()> = Mutex::new(());
fn publish_home_skill(root: &Path, name: &str, source: &Path, destination: &Path) -> Result<()> {
let _publication = HOME_SKILL_PUBLICATION
.lock()
.unwrap_or_else(PoisonError::into_inner);
if destination.join("SKILL.md").is_file() {
return Ok(());
}
let staged = tempfile::tempdir_in(
destination
.parent()
.context("home skill destination has no parent")?,
)
.with_context(|| format!("could not stage home skill {name}"))?;
copy_inside(source, staged.path(), root, 0)?;
if !staged.path().join("SKILL.md").is_file() {
bail!("could not publish home skill {name}");
}
fs::rename(staged.path(), destination)
.with_context(|| format!("could not publish home skill {name}"))?;
Ok(())
}
fn copy_inside(source: &Path, destination: &Path, root: &Path, depth: u32) -> Result<()> {
if depth > 32 {
bail!("home skill {} is nested too deeply", source.display());
}
fs::create_dir_all(destination)
.with_context(|| format!("could not create {}", destination.display()))?;
let entries =
fs::read_dir(source).with_context(|| format!("could not list {}", source.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("could not list {}", source.display()))?;
let path = entry.path();
if !resolved_inside(root, &path) {
continue;
}
let file_type = entry
.file_type()
.with_context(|| format!("could not inspect {}", path.display()))?;
let target = destination.join(entry.file_name());
if file_type.is_symlink() {
let metadata = fs::metadata(&path)
.with_context(|| format!("could not inspect {}", path.display()))?;
if metadata.is_dir() {
copy_inside(&path, &target, root, depth + 1)?;
} else if metadata.is_file() {
fs::copy(&path, &target)
.with_context(|| format!("could not copy {}", path.display()))?;
}
} else if file_type.is_dir() {
copy_inside(&path, &target, root, depth + 1)?;
} else if file_type.is_file() {
fs::copy(&path, &target)
.with_context(|| format!("could not copy {}", path.display()))?;
}
}
Ok(())
}
fn repository_has_skill(root: &Path, worktree: &Path, name: &str) -> bool {
[".agents/skills", ".claude/skills"]
.into_iter()
.any(|folder| {
let path = worktree.join(folder).join(name).join("SKILL.md");
resolved_inside(root, &path) && path.is_file()
})
}
fn home_link_allowed(destination: &Path, published: &Path, home_directory: &Path) -> Result<bool> {
match fs::symlink_metadata(destination) {
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => {
Err(error).with_context(|| format!("could not inspect {}", destination.display()))
}
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = fs::read_link(destination).ok();
Ok(target.as_deref() == Some(published) || target.as_deref() == Some(home_directory))
}
Ok(_) => Ok(false),
}
}
pub fn remove_written() {
drop(
WRITTEN
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take(),
);
}
pub(crate) fn written_out() -> Result<PathBuf> {
let mut written = WRITTEN.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(dir) = &*written {
return Ok(dir.path().to_owned());
}
let dir = tempfile::Builder::new()
.prefix("thirdshift-skills-")
.tempdir()?;
SKILLS
.extract(dir.path())
.context("could not write the Factory skills")?;
let path = dir.path().to_owned();
*written = Some(dir);
Ok(path)
}
pub(crate) fn exclude(git: &Git, exclude: &str) -> Result<()> {
let _lock = git.lock("thirdshift-exclude.lock")?;
let info = git.common_dir()?.join("info");
let path = info.join("exclude");
let existing = match fs::read_to_string(&path) {
Ok(existing) => existing,
Err(error) if error.kind() == ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(error).with_context(|| format!("could not read {}", path.display()));
}
};
if existing.lines().any(|line| line == exclude) {
return Ok(());
}
fs::create_dir_all(&info).with_context(|| format!("could not create {}", info.display()))?;
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| writeln!(file, "{separator}{exclude}"))
.with_context(|| format!("could not add an exclude pattern to {}", path.display()))
}