#![allow(unsafe_code, reason = "the environment is process-wide, and `Env` is what serialises it")]
mod common;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, PoisonError};
use common::{TempDir, installer, skills};
use skill_embed::{Agent, AgentSelector, Error, InstallOptions, Scope};
static LOCK: Mutex<()> = Mutex::new(());
const HOME_VAR: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
struct Env {
_guard: MutexGuard<'static, ()>,
home: Option<String>,
config: Option<String>,
cwd: PathBuf,
}
impl Env {
fn new() -> Self {
Self {
_guard: LOCK.lock().unwrap_or_else(PoisonError::into_inner),
home: std::env::var(HOME_VAR).ok(),
config: std::env::var("CLAUDE_CONFIG_DIR").ok(),
cwd: std::env::current_dir().expect("a working directory"),
}
}
fn set(key: &str, value: Option<&Path>) {
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
fn home(&self, dir: &Path) -> &Self {
Self::set(HOME_VAR, Some(dir));
self
}
#[expect(
clippy::unused_self,
reason = "taking the guard is how a caller shows it holds the lock"
)]
fn cd(&self, dir: &Path) -> PathBuf {
std::env::set_current_dir(dir).expect("the working directory");
std::env::current_dir().expect("a working directory")
}
}
impl Drop for Env {
fn drop(&mut self) {
for (key, value) in
[(HOME_VAR, self.home.clone()), ("CLAUDE_CONFIG_DIR", self.config.clone())]
{
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
let _ = std::env::set_current_dir(&self.cwd);
}
}
fn claude_only() -> InstallOptions {
InstallOptions { agents: vec![AgentSelector::from(&Agent::CLAUDE_CODE)], ..Default::default() }
}
#[test]
fn the_search_prefers_a_directory_that_already_holds_an_agent_directory() {
let tmp = TempDir::new("root-marker");
let env = Env::new();
env.home(&tmp.mkdir("home"));
tmp.mkdir("repo/.git");
tmp.mkdir("repo/sub/.claude");
let deeper = env.cd(&tmp.mkdir("repo/sub/deeper"));
let sub = deeper.parent().expect("repo/sub");
let skills = installer(skills(&["demo-skill"]));
let targets = skills.targets(&claude_only()).expect("the targets");
assert_eq!(targets[0].root.as_deref(), Some(sub));
assert_eq!(targets[0].dir, sub.join(".claude/skills"));
}
#[test]
fn the_search_stops_at_the_repository_root() {
let tmp = TempDir::new("root-repo");
let env = Env::new();
env.home(&tmp.mkdir("home"));
tmp.mkdir(".claude");
tmp.mkdir("repo/.git");
let sub = env.cd(&tmp.mkdir("repo/sub"));
let repo = sub.parent().expect("the repository root");
let skills = installer(skills(&["demo-skill"]));
let targets = skills.targets(&claude_only()).expect("the targets");
assert_eq!(targets[0].root.as_deref(), Some(repo));
}
#[test]
fn the_search_refuses_to_land_on_the_home_directory() {
let tmp = TempDir::new("root-home");
let env = Env::new();
let home = tmp.mkdir("home");
env.home(&home);
env.cd(&home);
let skills = installer(skills(&["demo-skill"]));
let Err(Error::ProjectIsHome { dir }) = skills.targets(&claude_only()) else {
panic!("a project install landed on the home directory");
};
assert!(dir.ends_with("home"), "the refusal does not name where it landed: {}", dir.display());
let message = Error::ProjectIsHome { dir }.to_string();
assert!(message.contains("--scope user"), "{message}");
assert!(message.contains("--dir"), "{message}");
}
#[test]
fn the_project_root_option_wins_over_the_search() {
let tmp = TempDir::new("root-option");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let named = tmp.mkdir("named");
env.cd(&tmp.mkdir("elsewhere"));
let skills = installer(skills(&["demo-skill"])).with_project_root(&named);
let targets = skills.targets(&claude_only()).expect("the targets");
assert_eq!(targets[0].dir, named.join(".claude/skills"));
}
#[test]
fn five_of_the_six_agents_share_one_project_directory() {
let tmp = TempDir::new("shared");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let root = tmp.mkdir("repo");
let skills = installer(skills(&["demo-skill"])).with_project_root(&root);
let options = InstallOptions { agents: vec![AgentSelector::All], ..Default::default() };
let targets = skills.targets(&options).expect("the targets");
assert_eq!(targets.len(), 2, "{targets:#?}");
let shared = targets
.iter()
.find(|t| t.dir == root.join(".agents/skills"))
.expect("the shared directory");
assert_eq!(shared.agents.len(), 5);
assert!(shared.label().contains("GitHub Copilot"), "{}", shared.label());
let (results, outcome) = skills.install(&options);
outcome.expect("the install");
assert_eq!(results.len(), 2, "a skill was written to one directory twice");
}
#[test]
fn user_scope_writes_under_the_home_directory() {
let tmp = TempDir::new("user-scope");
let env = Env::new();
let home = tmp.mkdir("home");
env.home(&home);
Env::set("CLAUDE_CONFIG_DIR", None);
let skills = installer(skills(&["demo-skill"]));
let options = InstallOptions { scope: Some(Scope::User), ..claude_only() };
let targets = skills.targets(&options).expect("the targets");
assert_eq!(targets[0].dir, home.join(".claude/skills"));
assert_eq!(targets[0].root, None, "user scope named a project root");
}
#[test]
fn claude_config_dir_is_honoured() {
let tmp = TempDir::new("config-dir");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let first = tmp.mkdir("first");
let separator = if cfg!(windows) { ';' } else { ':' };
Env::set(
"CLAUDE_CONFIG_DIR",
Some(&PathBuf::from(format!(
"{}{separator}{}",
first.display(),
tmp.join("second").display()
))),
);
let dir = Agent::CLAUDE_CODE.dir(Scope::User, None).expect("the user directory");
assert_eq!(dir, first.join("skills"));
}
#[test]
fn detected_falls_back_to_every_agent() {
let tmp = TempDir::new("detect");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let root = tmp.mkdir("repo");
let skills = installer(skills(&["demo-skill"])).with_project_root(&root);
let detected = InstallOptions { agents: vec![AgentSelector::Detected], ..Default::default() };
let targets = skills.targets(&detected).expect("the targets");
assert_eq!(targets.len(), 2, "a fresh repository did not reach everything");
tmp.mkdir("repo/.claude");
let targets = skills.targets(&detected).expect("the targets");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].dir, root.join(".claude/skills"));
}
#[test]
fn a_project_install_stays_inside_the_project() {
if !common::symlinks_available() {
return;
}
let tmp = TempDir::new("escape");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let root = tmp.mkdir("repo");
let outside = tmp.mkdir("outside");
common::link_dir(&outside, &root.join(".claude")).expect("the link");
let skills = installer(skills(&["demo-skill"])).with_project_root(&root);
let Err(Error::ProjectEscapes { real, .. }) = skills.targets(&claude_only()) else {
panic!("a link took a project install out of the project");
};
assert!(real.starts_with(std::fs::canonicalize(&outside).expect("the real path")));
assert!(
Error::ProjectEscapes { dir: root, real }.to_string().contains("--dir"),
"the refusal does not say how to write there on purpose"
);
}
#[test]
fn a_link_that_stays_inside_the_project_is_followed() {
if !common::symlinks_available() {
return;
}
let tmp = TempDir::new("inside-link");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let root = tmp.mkdir("repo");
tmp.mkdir("repo/shared");
common::link_dir(&root.join("shared"), &root.join(".claude")).expect("the link");
let skills = installer(skills(&["demo-skill"])).with_project_root(&root);
skills.targets(&claude_only()).expect("a link inside the project was refused");
}
#[test]
fn a_custom_agent_cannot_climb_out_of_the_project() {
let tmp = TempDir::new("climb");
let env = Env::new();
env.home(&tmp.mkdir("home"));
let root = tmp.mkdir("repo");
let escaping = Agent::new("escaping", "Escaping", "../elsewhere/skills");
let skills =
installer(skills(&["demo-skill"])).with_agents([escaping]).with_project_root(&root);
let options = InstallOptions { agents: vec![AgentSelector::All], ..Default::default() };
assert!(matches!(skills.targets(&options), Err(Error::ProjectEscapes { .. })));
}
#[test]
fn user_scope_is_not_bounded_the_same_way() {
if !common::symlinks_available() {
return;
}
let tmp = TempDir::new("user-link");
let env = Env::new();
let real_home = tmp.mkdir("real-home");
let linked = tmp.join("home");
common::link_dir(&real_home, &linked).expect("the link");
env.home(&linked);
Env::set("CLAUDE_CONFIG_DIR", None);
let skills = installer(skills(&["demo-skill"]));
let options = InstallOptions { scope: Some(Scope::User), ..claude_only() };
skills.install(&options).1.expect("a linked home directory was refused");
assert!(real_home.join(".claude/skills/demo-skill/SKILL.md").exists());
}