pub use crate::core::link::outcome::LinkOutcome;
mod outcome;
mod path;
#[cfg(test)]
mod tests;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::core::agents::{
Agent, Env, agent_skills_dir, canonical_skills_dir, disabled_skills_dir, is_native,
};
use crate::core::install::sanitize_name;
use crate::core::link::path::{agent_root_exists, classify, entry_name, is_legacy_link, points_to};
pub(crate) const MISC_DIR_NAME: &str = ".misc";
pub fn is_agent_linked(agent: &Agent, env: &Env) -> bool {
if is_native(agent, env) {
return true;
}
match agent_skills_dir(agent, env) {
Some(dir) => {
fs::symlink_metadata(&dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
&& points_to(&dir, &canonical_skills_dir(env))
}
None => false,
}
}
pub fn link_agent(agent: &Agent, env: &Env) -> LinkOutcome {
if is_native(agent, env) {
return LinkOutcome::AlreadyLinked;
}
let Some(agent_dir) = agent_skills_dir(agent, env) else {
return LinkOutcome::Skipped;
};
if !agent_root_exists(&agent_dir) && !agent.link_without_root {
return LinkOutcome::Skipped;
}
let canonical = canonical_skills_dir(env);
match fs::symlink_metadata(&agent_dir) {
Err(_) => link_dir(&canonical, &agent_dir),
Ok(meta) if meta.file_type().is_symlink() => {
if points_to(&agent_dir, &canonical) {
LinkOutcome::AlreadyLinked
} else {
LinkOutcome::Refused {
reason: format!(
"{} is a symlink pointing elsewhere; remove it first",
agent_dir.display()
),
}
}
}
Ok(_) => {
let entries = match fs::read_dir(&agent_dir) {
Err(e) => {
return LinkOutcome::Failed {
error: e.to_string(),
};
}
Ok(rd) => rd.flatten().collect::<Vec<_>>(),
};
if entries.is_empty() {
let _ = fs::remove_dir(&agent_dir);
return link_dir(&canonical, &agent_dir);
}
let (adopted, quarantined, conflicts) =
match adopt_all(agent, &canonical, &disabled_skills_dir(env), &entries) {
Ok(triple) => triple,
Err(error) => return LinkOutcome::Failed { error },
};
if let Err(e) = fs::remove_dir_all(&agent_dir) {
return LinkOutcome::Failed {
error: format!("remove {}: {e}", agent_dir.display()),
};
}
match create_dir_link(&canonical, &agent_dir) {
Ok(()) => LinkOutcome::Linked {
adopted,
quarantined,
conflicts,
},
Err(error) => LinkOutcome::Failed { error },
}
}
}
}
pub fn unlink_agent(agent: &Agent, env: &Env) -> LinkOutcome {
if is_native(agent, env) {
return LinkOutcome::NotLinked;
}
let Some(agent_dir) = agent_skills_dir(agent, env) else {
return LinkOutcome::NotLinked;
};
let canonical = canonical_skills_dir(env);
match fs::symlink_metadata(&agent_dir) {
Ok(meta) if meta.file_type().is_symlink() => {
if !points_to(&agent_dir, &canonical) {
return LinkOutcome::NotLinked;
}
if let Err(e) = remove_link(&agent_dir) {
return LinkOutcome::Failed {
error: e.to_string(),
};
}
if let Err(e) = fs::create_dir_all(&agent_dir) {
return LinkOutcome::Failed {
error: e.to_string(),
};
}
LinkOutcome::Unlinked
}
_ => LinkOutcome::NotLinked,
}
}
pub(crate) fn remove_link(link: &Path) -> std::io::Result<()> {
#[cfg(windows)]
let result = fs::remove_dir(link).or_else(|_| fs::remove_file(link));
#[cfg(not(windows))]
let result = fs::remove_file(link);
result
}
pub fn private_content(agent: &Agent, env: &Env) -> (Vec<String>, Vec<String>) {
let Some(dir) = agent_skills_dir(agent, env) else {
return (Vec::new(), Vec::new());
};
if fs::symlink_metadata(&dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return (Vec::new(), Vec::new());
}
let Ok(rd) = fs::read_dir(&dir) else {
return (Vec::new(), Vec::new());
};
let entries: Vec<_> = rd.flatten().collect();
if entries.is_empty() {
return (Vec::new(), Vec::new());
}
classify(&entries, &canonical_skills_dir(env))
}
type AdoptOutcome = (Vec<String>, Vec<String>, Vec<String>);
fn adopt_all(
agent: &Agent,
canonical: &Path,
disabled: &Path,
entries: &[fs::DirEntry],
) -> Result<AdoptOutcome, String> {
let mut adopted = Vec::new();
let mut quarantined = Vec::new();
let mut conflicts = Vec::new();
fs::create_dir_all(canonical).map_err(|e| format!("create {}: {e}", canonical.display()))?;
let mut taken = existing_names(&[canonical, disabled]);
let mut misc: Option<PathBuf> = None;
for entry in entries {
let name = entry_name(entry);
let from = entry.path();
if is_legacy_link(entry, canonical) {
conflicts.push(name);
continue;
}
if fs::metadata(&from).map(|m| m.is_dir()).unwrap_or(false) {
if taken.contains(&name) || taken.contains(&sanitize_name(&name)) {
conflicts.push(name);
continue;
}
let to = canonical.join(&name);
fs::rename(&from, &to).map_err(|e| format!("move {}: {e}", from.display()))?;
taken.insert(name.clone());
taken.insert(sanitize_name(&name));
adopted.push(name);
} else {
let dir = match &misc {
Some(dir) => dir.clone(),
None => {
let dir = canonical.join(MISC_DIR_NAME).join(&agent.name);
fs::create_dir_all(&dir)
.map_err(|e| format!("create {}: {e}", dir.display()))?;
misc = Some(dir.clone());
dir
}
};
let to = dir.join(&name);
fs::rename(&from, &to).map_err(|e| format!("move {}: {e}", from.display()))?;
quarantined.push(name);
}
}
Ok((adopted, quarantined, conflicts))
}
fn existing_names(dirs: &[&Path]) -> HashSet<String> {
let mut names = HashSet::new();
for dir in dirs {
let Ok(rd) = fs::read_dir(dir) else {
continue;
};
for entry in rd.flatten() {
let name = entry_name(&entry);
if name.starts_with('.') {
continue;
}
names.insert(sanitize_name(&name));
names.insert(name);
}
}
names
}
fn link_dir(canonical: &Path, agent_dir: &Path) -> LinkOutcome {
match create_dir_link(canonical, agent_dir) {
Ok(()) => LinkOutcome::Linked {
adopted: Vec::new(),
quarantined: Vec::new(),
conflicts: Vec::new(),
},
Err(error) => LinkOutcome::Failed { error },
}
}
fn create_dir_link(canonical: &Path, link: &Path) -> Result<(), String> {
if let Err(e) = fs::create_dir_all(canonical) {
return Err(format!("create {}: {e}", canonical.display()));
}
if let Some(parent) = link.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
return Err(format!("create {}: {e}", parent.display()));
}
#[cfg(unix)]
let result = std::os::unix::fs::symlink(relative_target(canonical, link), link);
#[cfg(windows)]
let result = create_windows_dir_link(canonical, link);
result.map_err(|e| {
let hint = if e.raw_os_error() == Some(1314) {
" (enable Windows Developer Mode, or keep the agent's dir on the \
same drive as the home directory)"
} else {
""
};
format!(
"link {} -> {}: {e}{hint}",
link.display(),
canonical.display()
)
})
}
#[cfg(unix)]
fn relative_target(canonical: &Path, link: &Path) -> PathBuf {
let base = link.parent().unwrap_or(Path::new("."));
pathdiff::diff_paths(canonical, base).unwrap_or_else(|| canonical.to_path_buf())
}
#[cfg(windows)]
fn create_windows_dir_link(canonical: &Path, link: &Path) -> std::io::Result<()> {
let target = std::path::absolute(canonical)?
.to_string_lossy()
.replace('/', "\\");
junction::create(&target, link).or_else(|_| std::os::windows::fs::symlink_dir(&target, link))
}