use include_dir::{include_dir, Dir, DirEntry};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub static EMBEDDED_SKILLS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/.claude/skills");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallTarget {
Claude,
Codex,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMode {
Copy,
Symlink,
}
#[derive(Debug, Default)]
pub struct InstallReport {
pub installed: Vec<PathBuf>,
pub skipped: Vec<PathBuf>,
pub overwrote: Vec<PathBuf>,
pub errors: Vec<(PathBuf, String)>,
}
impl InstallReport {
pub fn changes(&self) -> usize {
self.installed.len() + self.overwrote.len()
}
}
#[derive(Debug, Error)]
pub enum InstallError {
#[error("$HOME is not set")]
NoHome,
#[error(
"no valid install target — neither {claude} nor {codex} exists; \
create the parent directory (`mkdir -p ~/.claude` or `~/.codex`) and retry"
)]
NoTargetDir { claude: String, codex: String },
#[error("io: {0}")]
Io(#[from] io::Error),
}
pub fn install_skills(
target: InstallTarget,
mode: InstallMode,
force: bool,
dry_run: bool,
) -> Result<InstallReport, InstallError> {
let home = std::env::var("HOME").map_err(|_| InstallError::NoHome)?;
install_skills_at(&PathBuf::from(home), target, mode, force, dry_run)
}
pub fn install_skills_at(
home: &Path,
target: InstallTarget,
mode: InstallMode,
force: bool,
dry_run: bool,
) -> Result<InstallReport, InstallError> {
let dirs = resolve_targets(home, target)?;
let cache_dir = if mode == InstallMode::Symlink {
let cache = home.join(".local/share/heliosdb-proxy/skills");
if !dry_run {
extract_bundle_to(&cache)?;
}
Some(cache)
} else {
None
};
let mut report = InstallReport::default();
for dest_root in dirs {
deploy_to(
&dest_root,
cache_dir.as_deref(),
mode,
force,
dry_run,
&mut report,
)?;
}
Ok(report)
}
fn resolve_targets(home: &Path, target: InstallTarget) -> Result<Vec<PathBuf>, InstallError> {
let claude_root = home.join(".claude");
let codex_root = home.join(".codex");
let want_claude = matches!(target, InstallTarget::Claude | InstallTarget::Both);
let want_codex = matches!(target, InstallTarget::Codex | InstallTarget::Both);
let mut out = Vec::new();
if want_claude && claude_root.exists() {
out.push(claude_root.join("skills"));
}
if want_codex && codex_root.exists() {
out.push(codex_root.join("skills"));
}
if out.is_empty() {
return Err(InstallError::NoTargetDir {
claude: claude_root.display().to_string(),
codex: codex_root.display().to_string(),
});
}
Ok(out)
}
fn deploy_to(
dest_root: &Path,
cache_dir: Option<&Path>,
mode: InstallMode,
force: bool,
dry_run: bool,
report: &mut InstallReport,
) -> Result<(), InstallError> {
if !dry_run {
fs::create_dir_all(dest_root)?;
}
for entry in EMBEDDED_SKILLS.entries() {
let name = match entry.path().file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let dest = dest_root.join(name);
let pre_exists = dest.exists() || dest.is_symlink();
if pre_exists && !force {
report.skipped.push(dest);
continue;
}
if pre_exists {
if !dry_run {
remove_path(&dest)?;
}
report.overwrote.push(dest.clone());
}
match mode {
InstallMode::Copy => {
if !dry_run {
copy_entry(entry, &dest)?;
}
}
InstallMode::Symlink => {
let cache = cache_dir.expect("cache_dir set when symlink mode");
let src = cache.join(name);
if !dry_run {
create_symlink(&src, &dest)?;
}
}
}
report.installed.push(dest);
}
Ok(())
}
fn remove_path(p: &Path) -> io::Result<()> {
let meta = fs::symlink_metadata(p)?;
if meta.file_type().is_dir() {
fs::remove_dir_all(p)
} else {
fs::remove_file(p)
}
}
fn copy_entry(entry: &DirEntry<'_>, dest: &Path) -> io::Result<()> {
match entry {
DirEntry::Dir(d) => {
fs::create_dir_all(dest)?;
for child in d.entries() {
let child_name = child
.path()
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing file name"))?;
copy_entry(child, &dest.join(child_name))?;
}
}
DirEntry::File(f) => {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(dest, f.contents())?;
}
}
Ok(())
}
fn extract_bundle_to(target: &Path) -> io::Result<()> {
if target.exists() {
fs::remove_dir_all(target)?;
}
fs::create_dir_all(target)?;
EMBEDDED_SKILLS.extract(target)?;
Ok(())
}
#[cfg(unix)]
fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(windows)]
fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
if src.is_dir() {
std::os::windows::fs::symlink_dir(src, dst)
} else {
std::os::windows::fs::symlink_file(src, dst)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn embedded_bundle_has_overview_and_template() {
assert!(EMBEDDED_SKILLS.get_dir("heliosproxy-overview").is_some());
assert!(EMBEDDED_SKILLS.get_file("_template.md").is_some());
assert!(EMBEDDED_SKILLS.get_file("_index/verb-map.md").is_some());
}
#[test]
fn embedded_bundle_has_22_skills() {
let n = EMBEDDED_SKILLS
.entries()
.iter()
.filter(|e| matches!(e, DirEntry::Dir(d) if d.path().file_name().and_then(|f| f.to_str()).map(|n| n.starts_with("heliosproxy-")).unwrap_or(false)))
.count();
assert_eq!(n, 22, "expected 22 heliosproxy-* skill directories in the bundle");
}
#[test]
fn resolve_targets_errors_when_no_dirs_exist() {
let tmp = TempDir::new().unwrap();
let err = resolve_targets(tmp.path(), InstallTarget::Both).unwrap_err();
assert!(matches!(err, InstallError::NoTargetDir { .. }));
}
#[test]
fn resolve_targets_picks_existing_dirs() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let dirs = resolve_targets(tmp.path(), InstallTarget::Both).unwrap();
assert_eq!(dirs, vec![tmp.path().join(".claude/skills")]);
}
#[test]
fn install_copy_mode_writes_skill_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let report =
install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, false)
.unwrap();
assert!(report.changes() >= 22);
let f = tmp.path().join(".claude/skills/heliosproxy-overview/SKILL.md");
assert!(f.exists());
let body = fs::read_to_string(&f).unwrap();
assert!(body.contains("HeliosProxy"));
}
#[test]
fn install_skips_existing_without_force() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude/skills/heliosproxy-overview")).unwrap();
let report =
install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, false)
.unwrap();
assert!(report.skipped.iter().any(|p| p.ends_with("heliosproxy-overview")));
}
#[test]
fn install_force_overwrites() {
let tmp = TempDir::new().unwrap();
let pre = tmp.path().join(".claude/skills/heliosproxy-overview");
fs::create_dir_all(&pre).unwrap();
fs::write(pre.join("stale.txt"), b"old").unwrap();
let report =
install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, true, false)
.unwrap();
assert!(report.overwrote.iter().any(|p| p.ends_with("heliosproxy-overview")));
assert!(!pre.join("stale.txt").exists());
assert!(pre.join("SKILL.md").exists());
}
#[test]
fn dry_run_writes_nothing() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let report =
install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, true)
.unwrap();
assert!(report.changes() >= 22);
assert!(!tmp.path().join(".claude/skills/heliosproxy-overview").exists());
}
#[cfg(unix)]
#[test]
fn install_symlink_mode_creates_symlinks() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let report = install_skills_at(
tmp.path(),
InstallTarget::Claude,
InstallMode::Symlink,
false,
false,
)
.unwrap();
assert!(report.changes() >= 22);
let link = tmp.path().join(".claude/skills/heliosproxy-overview");
let meta = fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink());
let target = fs::read_link(&link).unwrap();
assert!(
target
.to_string_lossy()
.contains(".local/share/heliosdb-proxy/skills"),
"symlink target unexpected: {}",
target.display()
);
let cache = tmp
.path()
.join(".local/share/heliosdb-proxy/skills/heliosproxy-overview/SKILL.md");
assert!(cache.exists());
}
#[cfg(unix)]
#[test]
fn install_symlink_then_force_replaces_link() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
install_skills_at(
tmp.path(),
InstallTarget::Claude,
InstallMode::Symlink,
false,
false,
)
.unwrap();
let report = install_skills_at(
tmp.path(),
InstallTarget::Claude,
InstallMode::Symlink,
true, false,
)
.unwrap();
assert!(report.changes() >= 22);
}
}