use std::{
fs,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
time::Duration,
};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::types::{
ActiveSkillRecord, SkillCatalogEntry, SkillInstallMode, SkillRootView, SkillScope,
SkillsRuntimeView,
};
const SKILL_ENTRYPOINT: &str = "SKILL.md";
const INSTALL_METADATA_FILENAME: &str = ".holon-skill-install.json";
const SKILL_ROOT_SUFFIXES: [&str; 4] = [
"skills",
".agents/skills",
".codex/skills",
".claude/skills",
];
const COMPAT_SKILL_ROOT_SUFFIXES: [&str; 3] = [".agents/skills", ".codex/skills", ".claude/skills"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillVisibility {
DefaultAgent,
NonDefaultAgent,
}
pub fn load_skills_runtime_view(
visibility: SkillVisibility,
user_home: Option<&Path>,
agent_home: &Path,
workspace_anchor: Option<&Path>,
active_skills: &[ActiveSkillRecord],
) -> Result<SkillsRuntimeView> {
let mut discovered_roots = Vec::new();
let mut discoverable_skills = Vec::new();
if matches!(visibility, SkillVisibility::DefaultAgent) {
if let Some(root) = select_skill_root(user_home, &COMPAT_SKILL_ROOT_SUFFIXES) {
discoverable_skills.extend(load_catalog_for_scope(SkillScope::User, &root)?);
discovered_roots.push(SkillRootView {
scope: SkillScope::User,
path: root,
});
}
}
for root in existing_skill_roots(Some(agent_home), &SKILL_ROOT_SUFFIXES) {
discoverable_skills.extend(load_catalog_for_scope(SkillScope::Agent, &root)?);
discovered_roots.push(SkillRootView {
scope: SkillScope::Agent,
path: root,
});
}
if let Some(root) = select_skill_root(workspace_anchor, &COMPAT_SKILL_ROOT_SUFFIXES) {
discoverable_skills.extend(load_catalog_for_scope(SkillScope::Workspace, &root)?);
discovered_roots.push(SkillRootView {
scope: SkillScope::Workspace,
path: root,
});
}
discoverable_skills.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
let attached_skills = discoverable_skills.clone();
let active_skill_ids = discoverable_skills
.iter()
.map(|entry| entry.skill_id.as_str())
.collect::<std::collections::BTreeSet<_>>();
let active_skills = active_skills
.iter()
.filter(|record| active_skill_ids.contains(record.skill_id.as_str()))
.cloned()
.collect::<Vec<_>>();
Ok(SkillsRuntimeView {
agent_templates_catalog: Vec::new(),
discovered_roots,
discoverable_skills,
attached_skills,
active_skills,
})
}
pub fn find_skill_by_entrypoint<'a>(
skills: &'a [SkillCatalogEntry],
path: &Path,
) -> Option<&'a SkillCatalogEntry> {
skills.iter().find(|entry| entry.path == path)
}
pub fn find_skill_by_script_path<'a>(
skills: &'a [SkillCatalogEntry],
path: &Path,
) -> Option<&'a SkillCatalogEntry> {
skills.iter().find(|entry| {
entry
.path
.parent()
.map(|skill_root| path.starts_with(skill_root.join("scripts")))
.unwrap_or(false)
})
}
fn select_skill_root(base: Option<&Path>, suffixes: &[&str]) -> Option<PathBuf> {
let base = base?;
for suffix in suffixes {
let candidate = base.join(suffix);
if candidate.is_dir() {
return Some(candidate);
}
}
None
}
fn existing_skill_roots(base: Option<&Path>, suffixes: &[&str]) -> Vec<PathBuf> {
let Some(base) = base else {
return Vec::new();
};
suffixes
.iter()
.map(|suffix| base.join(suffix))
.filter(|candidate| candidate.is_dir())
.collect()
}
fn load_catalog_for_scope(scope: SkillScope, root: &Path) -> Result<Vec<SkillCatalogEntry>> {
let mut entries = Vec::new();
let read_dir = match fs::read_dir(root) {
Ok(read_dir) => read_dir,
Err(error) => {
warn!("skipping unreadable skill root {}: {error}", root.display());
return Ok(entries);
}
};
for child in read_dir {
let child = match child {
Ok(child) => child,
Err(error) => {
warn!(
"skipping unreadable skill entry in {}: {error}",
root.display()
);
continue;
}
};
let file_type = match child.file_type() {
Ok(file_type) => file_type,
Err(error) => {
warn!(
"skipping skill entry with unreadable type {}: {error}",
child.path().display()
);
continue;
}
};
if !file_type.is_dir() && !file_type.is_symlink() {
continue;
}
let skill_name = child.file_name().to_string_lossy().to_string();
let skill_path = child.path().join(SKILL_ENTRYPOINT);
if !skill_path.is_file() {
continue;
}
let content = match fs::read_to_string(&skill_path) {
Ok(content) => content,
Err(error) => {
warn!(
"skipping unreadable skill file {}: {error}",
skill_path.display()
);
continue;
}
};
let parsed = parse_skill_metadata(&content);
let name = parsed.name.unwrap_or_else(|| skill_name.clone());
let description = parsed
.description
.unwrap_or_else(|| first_body_paragraph(&content));
entries.push(SkillCatalogEntry {
skill_id: format!("{}:{skill_name}", scope_label(scope)),
name,
description,
path: skill_path,
scope,
});
}
Ok(entries)
}
#[derive(Default)]
struct ParsedSkillMetadata {
name: Option<String>,
description: Option<String>,
}
fn parse_skill_metadata(content: &str) -> ParsedSkillMetadata {
let mut parsed = ParsedSkillMetadata::default();
let mut lines = content.lines();
if lines.next() != Some("---") {
return parsed;
}
for line in lines {
if line == "---" {
break;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
match key.trim() {
"name" if !value.is_empty() => parsed.name = Some(value),
"description" if !value.is_empty() => parsed.description = Some(value),
_ => {}
}
}
parsed
}
fn first_body_paragraph(content: &str) -> String {
let body = if content.starts_with("---\n") || content.starts_with("---\r\n") {
content
.split_once("\n---\n")
.map(|(_, body)| body)
.or_else(|| content.split_once("\r\n---\r\n").map(|(_, body)| body))
.unwrap_or(content)
} else {
content
};
body.split("\n\n")
.map(str::trim)
.find(|paragraph| !paragraph.is_empty())
.unwrap_or_default()
.replace('\n', " ")
}
fn scope_label(scope: SkillScope) -> &'static str {
match scope {
SkillScope::User => "user",
SkillScope::Agent => "agent",
SkillScope::Workspace => "workspace",
}
}
const SKILL_ROOT_SUFFIX_AGENT: &str = "skills";
fn agent_skills_root(agent_home: &Path) -> PathBuf {
if let Some(existing) = select_skill_root(Some(agent_home), &SKILL_ROOT_SUFFIXES) {
existing
} else {
agent_home.join(SKILL_ROOT_SUFFIX_AGENT)
}
}
#[derive(Debug, Clone)]
pub struct SkillInstallConflict {
pub skill_name: String,
pub destination: PathBuf,
}
impl std::fmt::Display for SkillInstallConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"skill '{}' is already installed at {}; uninstall it first or choose a different skill name",
self.skill_name,
self.destination.display()
)
}
}
impl std::error::Error for SkillInstallConflict {}
#[derive(Debug, Clone)]
pub struct SkillManagerUnavailable {
pub manager: String,
}
impl std::fmt::Display for SkillManagerUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "remote skill install requires {}", self.manager)
}
}
impl std::error::Error for SkillManagerUnavailable {}
#[derive(Debug, Clone)]
pub struct RemoteSkillInstallFailed {
pub package: String,
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl std::fmt::Display for RemoteSkillInstallFailed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(status) => write!(
f,
"remote skill install for '{}' failed with exit status {}",
self.package, status
),
None => write!(
f,
"remote skill install for '{}' failed before process exit",
self.package
),
}
}
}
impl std::error::Error for RemoteSkillInstallFailed {}
#[derive(Debug, Clone)]
pub struct RemoteSkillInstallTimedOut {
pub package: String,
pub timeout: Duration,
}
impl std::fmt::Display for RemoteSkillInstallTimedOut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"remote skill install for '{}' timed out after {}s",
self.package,
self.timeout.as_secs()
)
}
}
impl std::error::Error for RemoteSkillInstallTimedOut {}
trait RemoteSkillInstaller {
fn add_global(&self, package: &str, skill: Option<&str>) -> Result<()>;
}
struct NpxRemoteSkillInstaller;
impl RemoteSkillInstaller for NpxRemoteSkillInstaller {
fn add_global(&self, package: &str, skill: Option<&str>) -> Result<()> {
const REMOTE_SKILL_INSTALL_TIMEOUT: Duration = Duration::from_secs(120);
match command_output_with_timeout(
Command::new("npx").arg("--version"),
Duration::from_secs(10),
) {
Ok(Some(_)) => {}
Ok(None) => {
return Err(RemoteSkillInstallTimedOut {
package: package.into(),
timeout: Duration::from_secs(10),
}
.into());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(SkillManagerUnavailable {
manager: "npx".into(),
}
.into());
}
Err(error) => return Err(error).context("failed to check npx availability"),
}
let mut command = Command::new("npx");
command.args(["--yes", "skills", "add", package, "--global", "--yes"]);
if let Some(skill) = skill {
command.args(["--skill", skill]);
}
let output = command_output_with_timeout(&mut command, REMOTE_SKILL_INSTALL_TIMEOUT)
.with_context(|| format!("failed to run npx skills add for {package}"))?;
let Some(output) = output else {
return Err(RemoteSkillInstallTimedOut {
package: package.into(),
timeout: REMOTE_SKILL_INSTALL_TIMEOUT,
}
.into());
};
if !output.status.success() {
return Err(RemoteSkillInstallFailed {
package: package.into(),
status: output.status.code(),
stdout: bounded_output_excerpt(&output.stdout),
stderr: bounded_output_excerpt(&output.stderr),
}
.into());
}
Ok(())
}
}
fn command_output_with_timeout(
command: &mut Command,
timeout: Duration,
) -> std::io::Result<Option<Output>> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let deadline = std::time::Instant::now() + timeout;
loop {
if child.try_wait()?.is_some() {
return child.wait_with_output().map(Some);
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[cfg(test)]
#[derive(Clone)]
struct RecordedRemoteSkillInstall {
package: String,
skill: Option<String>,
}
#[cfg(test)]
struct RecordingRemoteSkillInstaller {
calls: std::sync::Arc<std::sync::Mutex<Vec<RecordedRemoteSkillInstall>>>,
}
#[cfg(test)]
impl RemoteSkillInstaller for RecordingRemoteSkillInstaller {
fn add_global(&self, package: &str, skill: Option<&str>) -> Result<()> {
self.calls.lock().unwrap().push(RecordedRemoteSkillInstall {
package: package.into(),
skill: skill.map(str::to_string),
});
Ok(())
}
}
#[cfg(test)]
struct UnavailableRemoteSkillInstaller;
#[cfg(test)]
impl RemoteSkillInstaller for UnavailableRemoteSkillInstaller {
fn add_global(&self, _package: &str, _skill: Option<&str>) -> Result<()> {
Err(SkillManagerUnavailable {
manager: "npx".into(),
}
.into())
}
}
fn bounded_output_excerpt(bytes: &[u8]) -> String {
const MAX_OUTPUT_BYTES: usize = 2048;
let excerpt = if bytes.len() > MAX_OUTPUT_BYTES {
&bytes[..MAX_OUTPUT_BYTES]
} else {
bytes
};
String::from_utf8_lossy(excerpt).to_string()
}
fn validate_skill_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains(std::path::is_separator)
|| name == "."
|| name == ".."
|| name.contains("..")
{
bail!(
"invalid skill name '{}': must be a single path component without traversal",
name
);
}
Ok(())
}
fn install_destination(skills_root: &Path, skill_name: &str) -> Result<PathBuf> {
validate_skill_name(skill_name)?;
let destination = skills_root.join(skill_name);
if fs::symlink_metadata(&destination).is_ok() {
return Err(SkillInstallConflict {
skill_name: skill_name.to_string(),
destination,
}
.into());
}
Ok(destination)
}
pub fn install_skill(agent_home: &Path, kind: &crate::types::SkillInstallKind) -> Result<String> {
install_skill_with_user_home(agent_home, None, kind)
}
pub fn install_skill_with_user_home(
agent_home: &Path,
user_home: Option<&Path>,
kind: &crate::types::SkillInstallKind,
) -> Result<String> {
install_skill_with_user_home_and_remote_installer(
agent_home,
user_home,
kind,
&NpxRemoteSkillInstaller,
)
}
fn install_skill_with_user_home_and_remote_installer(
agent_home: &Path,
user_home: Option<&Path>,
kind: &crate::types::SkillInstallKind,
remote_installer: &dyn RemoteSkillInstaller,
) -> Result<String> {
let skills_root = agent_skills_root(agent_home);
fs::create_dir_all(&skills_root)
.with_context(|| format!("failed to create {}", skills_root.display()))?;
let name = match kind {
crate::types::SkillInstallKind::Builtin { name } => {
validate_skill_name(name)?;
let _ = install_destination(&skills_root, name)?;
let destination =
crate::agent_template::materialize_builtin_skill_ref(&skills_root, name)?;
record_install_metadata(&destination, "builtin")?;
name.clone()
}
crate::types::SkillInstallKind::Named { name, mode } => {
validate_skill_name(name)?;
if crate::agent_template::builtin_skill_names().contains(&name.as_str()) {
let _ = install_destination(&skills_root, name)?;
let destination =
crate::agent_template::materialize_builtin_skill_ref(&skills_root, name)?;
record_install_metadata(&destination, "builtin")?;
name.clone()
} else {
let path = resolve_user_skill_by_name(user_home, name)?;
materialize_local_skill(&skills_root, &path, mode)?
}
}
crate::types::SkillInstallKind::Local { path, mode } => {
materialize_local_skill(&skills_root, path, mode)?
}
crate::types::SkillInstallKind::Remote {
package,
skill,
mode,
} => {
validate_remote_package(package)?;
if let Some(skill) = skill {
validate_skill_name(skill)?;
}
remote_installer.add_global(package, skill.as_deref())?;
let skill_name = match skill {
Some(skill) => skill.as_str(),
None => remote_package_default_skill_name(package)?,
};
let path = resolve_user_skill_by_name(user_home, skill_name)?;
materialize_local_skill(&skills_root, &path, mode)?
}
};
Ok(name)
}
fn validate_remote_package(package: &str) -> Result<()> {
if package.trim().is_empty() {
bail!("remote skill package must not be empty");
}
if package.trim() != package {
bail!("remote skill package must not contain leading or trailing whitespace");
}
if package.starts_with('-') {
bail!("remote skill package must not start with '-'");
}
if package
.chars()
.any(|ch| ch.is_control() || ch.is_ascii_whitespace())
{
bail!("remote skill package must not contain whitespace or control characters");
}
Ok(())
}
fn remote_package_default_skill_name(package: &str) -> Result<&str> {
package
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.ok_or_else(|| anyhow::anyhow!("remote skill package has no usable default skill name"))
}
fn materialize_local_skill(
skills_root: &Path,
path: &Path,
mode: &SkillInstallMode,
) -> Result<String> {
let path = normalize_local_skill_path(path)?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
validate_skill_name(file_name)?;
let _ = install_destination(skills_root, file_name)?;
let destination = match mode {
SkillInstallMode::Linked => materialize_linked_skill_ref(skills_root, &path)?,
SkillInstallMode::Copied => materialize_copied_skill_ref(skills_root, &path)?,
};
if *mode == SkillInstallMode::Copied {
record_install_metadata(&destination, "copied")?;
}
Ok(destination
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_default())
}
fn normalize_local_skill_path(path: &Path) -> Result<PathBuf> {
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to resolve current directory for local skill path")?
.join(path)
};
if !resolved.is_dir() {
bail!("local skill directory does not exist: {}", path.display());
}
let entrypoint = resolved.join(SKILL_ENTRYPOINT);
if !entrypoint.is_file() {
bail!(
"local skill ref {} does not contain {}",
resolved.display(),
SKILL_ENTRYPOINT
);
}
Ok(resolved)
}
fn resolve_user_skill_by_name(user_home: Option<&Path>, name: &str) -> Result<PathBuf> {
for root in existing_skill_roots(user_home, &COMPAT_SKILL_ROOT_SUFFIXES) {
let mut root_matches = Vec::new();
for skill in load_catalog_for_scope(SkillScope::User, &root)? {
let dir_name = skill
.path
.parent()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.unwrap_or("");
if skill.name == name || dir_name == name {
if let Some(skill_dir) = skill.path.parent() {
root_matches.push(skill_dir.to_path_buf());
}
}
}
root_matches.sort();
root_matches.dedup();
match root_matches.as_slice() {
[] => {}
[path] => return Ok(path.clone()),
paths => bail!(
"skill '{}' matched multiple user-global skill directories under {}: {}; use an explicit path",
name,
root.display(),
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
}
}
bail!(
"skill '{}' is not a builtin skill and was not found in the user-global skill catalog; use an explicit path",
name
)
}
fn materialize_linked_skill_ref(skills_root: &Path, path: &Path) -> Result<PathBuf> {
crate::agent_template::materialize_local_skill_ref(skills_root, path)
}
fn materialize_copied_skill_ref(skills_root: &Path, path: &Path) -> Result<PathBuf> {
fs::create_dir_all(skills_root)
.with_context(|| format!("failed to create {}", skills_root.display()))?;
let skill_name = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"local skill ref {} has no usable directory name",
path.display()
)
})?;
let destination = skills_root.join(skill_name);
if destination.exists() {
bail!(
"local skill destination {} already exists",
destination.display()
);
}
if let Err(error) = copy_dir_all(path, &destination).with_context(|| {
format!(
"failed to copy local skill ref {} -> {}",
path.display(),
destination.display()
)
}) {
let _ = fs::remove_dir_all(&destination);
return Err(error);
}
Ok(destination)
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
for entry in fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_all(&src_path, &dst_path)?;
} else if file_type.is_file() {
fs::copy(&src_path, &dst_path).with_context(|| {
format!(
"failed to copy {} -> {}",
src_path.display(),
dst_path.display()
)
})?;
} else if file_type.is_symlink() {
bail!(
"copy mode does not support symlinks inside skill directories: {}",
src_path.display()
);
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct SkillInstallMetadata {
install_mode: String,
}
fn record_install_metadata(destination: &Path, install_mode: &str) -> Result<()> {
let payload = serde_json::to_vec_pretty(&SkillInstallMetadata {
install_mode: install_mode.into(),
})?;
fs::write(destination.join(INSTALL_METADATA_FILENAME), payload).with_context(|| {
format!(
"failed to write install metadata for {}",
destination.display()
)
})
}
fn read_install_metadata(skill_dir: &Path) -> Option<SkillInstallMetadata> {
let content = fs::read(skill_dir.join(INSTALL_METADATA_FILENAME)).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn uninstall_skill(agent_home: &Path, name: &str) -> Result<()> {
validate_skill_name(name)?;
let mut matches = Vec::new();
for skills_root in existing_skill_roots(Some(agent_home), &SKILL_ROOT_SUFFIXES) {
let destination = skills_root.join(name);
if let Ok(meta) = fs::symlink_metadata(&destination) {
matches.push((destination, meta));
}
}
let [(destination, meta)] = matches.as_slice() else {
if matches.is_empty() {
bail!(
"skill '{}' is not installed in agent skills directory",
name
);
}
bail!(
"skill '{}' is installed in multiple agent skill roots: {}; remove the duplicate directories manually",
name,
matches
.iter()
.map(|(path, _)| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
};
if !destination.join(SKILL_ENTRYPOINT).exists() && !meta.is_symlink() {
bail!(
"directory '{}' does not appear to be a skill installation (missing {})",
destination.display(),
SKILL_ENTRYPOINT
);
}
crate::agent_template::remove_materialized_skill_destination(&destination)?;
Ok(())
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct InstalledSkillView {
#[serde(flatten)]
pub catalog: SkillCatalogEntry,
pub install_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
pub fn list_installed_skills(agent_home: &Path) -> Result<Vec<InstalledSkillView>> {
let mut entries = Vec::new();
for skills_root in existing_skill_roots(Some(agent_home), &SKILL_ROOT_SUFFIXES) {
let read_dir = match fs::read_dir(&skills_root) {
Ok(read_dir) => read_dir,
Err(error) => {
warn!(
"skipping unreadable installed skill root {}: {error}",
skills_root.display()
);
continue;
}
};
for child in read_dir {
let child = child?;
let file_type = child.file_type()?;
if !file_type.is_dir() && !file_type.is_symlink() {
continue;
}
let skill_name = child.file_name().to_string_lossy().to_string();
let skill_path = child.path().join(SKILL_ENTRYPOINT);
let catalog = if skill_path.is_file() {
let content = fs::read_to_string(&skill_path).with_context(|| {
format!("failed to read installed skill {}", skill_path.display())
})?;
let parsed = parse_skill_metadata(&content);
SkillCatalogEntry {
skill_id: format!("agent:{skill_name}"),
name: parsed.name.unwrap_or_else(|| skill_name.clone()),
description: parsed
.description
.unwrap_or_else(|| first_body_paragraph(&content)),
path: skill_path,
scope: SkillScope::Agent,
}
} else {
SkillCatalogEntry {
skill_id: format!("agent:{skill_name}"),
name: skill_name.clone(),
description: String::new(),
path: skill_path,
scope: SkillScope::Agent,
}
};
entries.push(installed_skill_view(catalog)?);
}
}
entries.sort_by(|left, right| {
left.catalog
.skill_id
.cmp(&right.catalog.skill_id)
.then_with(|| left.catalog.path.cmp(&right.catalog.path))
});
Ok(entries)
}
fn installed_skill_view(catalog: SkillCatalogEntry) -> Result<InstalledSkillView> {
let skill_dir = catalog
.path
.parent()
.ok_or_else(|| anyhow::anyhow!("skill path {} has no parent", catalog.path.display()))?;
let metadata = fs::symlink_metadata(skill_dir)
.with_context(|| format!("failed to inspect {}", skill_dir.display()))?;
let link_target = if metadata.file_type().is_symlink() {
Some(
fs::read_link(skill_dir)
.with_context(|| format!("failed to read link {}", skill_dir.display()))?,
)
} else {
None
};
let install_metadata = if link_target.is_none() {
read_install_metadata(skill_dir)
} else {
None
};
let warning = link_target
.as_ref()
.and_then(|target| {
let target_path = if target.is_absolute() {
target.clone()
} else {
skill_dir
.parent()
.unwrap_or_else(|| Path::new(""))
.join(target)
};
if !target_path.exists() {
Some(format!("link target does not exist: {}", target.display()))
} else if !target_path.join(SKILL_ENTRYPOINT).is_file() {
Some(format!(
"link target does not contain {}: {}",
SKILL_ENTRYPOINT,
target.display()
))
} else {
None
}
})
.or_else(|| {
if !catalog.path.is_file() {
Some(format!(
"installed skill does not contain {}: {}",
SKILL_ENTRYPOINT,
skill_dir.display()
))
} else {
None
}
});
Ok(InstalledSkillView {
install_mode: if link_target.is_some() {
"linked".into()
} else if matches!(
install_metadata
.as_ref()
.map(|metadata| metadata.install_mode.as_str()),
Some("builtin")
) {
"builtin".into()
} else if matches!(
install_metadata
.as_ref()
.map(|metadata| metadata.install_mode.as_str()),
Some("copied")
) {
"copied".into()
} else if crate::agent_template::builtin_skill_names()
.contains(&catalog.skill_id.trim_start_matches("agent:"))
{
"builtin".into()
} else {
"copied".into()
},
catalog,
link_target,
warning,
})
}
#[cfg(all(test, unix))]
fn create_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(all(test, windows))]
fn create_symlink(src: &Path, dst: &Path) -> std::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 tempfile::tempdir;
use super::*;
use crate::types::{SkillActivationSource, SkillActivationState};
#[test]
fn agent_skill_discovery_merges_compatible_roots() {
let dir = tempdir().unwrap();
let base = dir.path();
let first_root = base.join("skills");
let fallback_root = base.join(".codex/skills");
fs::create_dir_all(first_root.join("alpha")).unwrap();
fs::create_dir_all(fallback_root.join("beta")).unwrap();
fs::write(
first_root.join("alpha").join(SKILL_ENTRYPOINT),
"---\nname: alpha\ndescription: first\n---\nbody",
)
.unwrap();
fs::write(
fallback_root.join("beta").join(SKILL_ENTRYPOINT),
"---\nname: beta\ndescription: second\n---\nbody",
)
.unwrap();
let view =
load_skills_runtime_view(SkillVisibility::NonDefaultAgent, None, base, None, &[])
.unwrap();
assert_eq!(view.discovered_roots.len(), 2);
let names = view
.discoverable_skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn default_agent_sees_user_scope_but_non_default_does_not() {
let dir = tempdir().unwrap();
let user_home = dir.path().join("user");
let agent_home = dir.path().join("agent");
fs::create_dir_all(user_home.join(".agents/skills/ghx")).unwrap();
fs::create_dir_all(agent_home.join("skills/local")).unwrap();
fs::write(
user_home.join(".agents/skills/ghx").join(SKILL_ENTRYPOINT),
"---\nname: ghx\ndescription: user skill\n---\nbody",
)
.unwrap();
fs::write(
agent_home.join("skills/local").join(SKILL_ENTRYPOINT),
"---\nname: local\ndescription: agent skill\n---\nbody",
)
.unwrap();
let default_view = load_skills_runtime_view(
SkillVisibility::DefaultAgent,
Some(&user_home),
&agent_home,
None,
&[],
)
.unwrap();
let non_default_view = load_skills_runtime_view(
SkillVisibility::NonDefaultAgent,
Some(&user_home),
&agent_home,
None,
&[],
)
.unwrap();
assert!(default_view
.discoverable_skills
.iter()
.any(|entry| entry.scope == SkillScope::User));
assert!(non_default_view
.discoverable_skills
.iter()
.all(|entry| entry.scope != SkillScope::User));
}
#[test]
fn frontmatter_description_wins_over_body() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/demo");
fs::create_dir_all(&root).unwrap();
fs::write(
root.join(SKILL_ENTRYPOINT),
"---\nname: demo\ndescription: frontmatter description\n---\n\nParagraph description",
)
.unwrap();
let view = load_skills_runtime_view(
SkillVisibility::NonDefaultAgent,
None,
dir.path(),
None,
&[],
)
.unwrap();
assert_eq!(
view.discoverable_skills[0].description,
"frontmatter description"
);
}
#[test]
fn filters_active_skills_to_visible_catalog() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/demo");
fs::create_dir_all(&root).unwrap();
let skill_path = root.join(SKILL_ENTRYPOINT);
fs::write(
&skill_path,
"---\nname: demo\ndescription: demo skill\n---\nbody",
)
.unwrap();
let view = load_skills_runtime_view(
SkillVisibility::NonDefaultAgent,
None,
dir.path(),
None,
&[ActiveSkillRecord {
skill_id: "agent:demo".into(),
name: "demo".into(),
path: skill_path,
scope: SkillScope::Agent,
agent_id: "default".into(),
activation_source: SkillActivationSource::ImplicitFromCatalog,
activation_state: SkillActivationState::TurnActive,
activated_at_turn: 1,
}],
)
.unwrap();
assert_eq!(view.active_skills.len(), 1);
}
#[test]
fn skips_unreadable_skill_entries_without_failing_catalog_load() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills");
fs::create_dir_all(root.join("good")).unwrap();
fs::create_dir_all(root.join("bad")).unwrap();
fs::write(
root.join("good").join(SKILL_ENTRYPOINT),
"---\nname: good\ndescription: good skill\n---\nbody",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::write(root.join("bad").join(SKILL_ENTRYPOINT), "secret").unwrap();
let mut perms = fs::metadata(root.join("bad").join(SKILL_ENTRYPOINT))
.unwrap()
.permissions();
perms.set_mode(0o000);
fs::set_permissions(root.join("bad").join(SKILL_ENTRYPOINT), perms).unwrap();
}
let view = load_skills_runtime_view(
SkillVisibility::NonDefaultAgent,
None,
dir.path(),
None,
&[],
)
.unwrap();
assert_eq!(view.discoverable_skills.len(), 1);
assert_eq!(view.discoverable_skills[0].name, "good");
}
#[test]
fn named_install_links_unique_user_global_skill_for_non_default_agent() {
let dir = tempdir().unwrap();
let user_home = dir.path().join("user");
let agent_home = dir.path().join("agent");
let source = user_home.join(".agents/skills/global-demo");
fs::create_dir_all(&source).unwrap();
fs::write(
source.join(SKILL_ENTRYPOINT),
"---\nname: global-demo\ndescription: global skill\n---\nbody",
)
.unwrap();
let installed = install_skill_with_user_home(
&agent_home,
Some(&user_home),
&crate::types::SkillInstallKind::Named {
name: "global-demo".into(),
mode: SkillInstallMode::Linked,
},
)
.unwrap();
assert_eq!(installed, "global-demo");
let destination = agent_home.join("skills/global-demo");
assert!(fs::symlink_metadata(&destination)
.unwrap()
.file_type()
.is_symlink());
let view = load_skills_runtime_view(
SkillVisibility::NonDefaultAgent,
Some(&user_home),
&agent_home,
None,
&[],
)
.unwrap();
assert_eq!(view.discoverable_skills.len(), 1);
assert_eq!(view.discoverable_skills[0].scope, SkillScope::Agent);
assert_eq!(view.discoverable_skills[0].name, "global-demo");
}
#[test]
fn local_install_can_copy_skill_directory() {
let dir = tempdir().unwrap();
let source = dir.path().join("source/demo");
let agent_home = dir.path().join("agent");
fs::create_dir_all(source.join("references")).unwrap();
fs::write(
source.join(SKILL_ENTRYPOINT),
"---\nname: demo\ndescription: copied skill\n---\nbody",
)
.unwrap();
fs::write(source.join("references/notes.md"), "notes").unwrap();
let installed = install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Local {
path: source.clone(),
mode: SkillInstallMode::Copied,
},
)
.unwrap();
assert_eq!(installed, "demo");
let destination = agent_home.join("skills/demo");
assert!(destination.join(SKILL_ENTRYPOINT).is_file());
assert!(destination.join("references/notes.md").is_file());
assert!(!fs::symlink_metadata(&destination)
.unwrap()
.file_type()
.is_symlink());
let installed = list_installed_skills(&agent_home).unwrap();
assert_eq!(installed[0].install_mode, "copied");
assert!(installed[0].link_target.is_none());
}
#[test]
fn copied_local_skill_named_like_builtin_reports_copied_mode() {
let dir = tempdir().unwrap();
let source = dir.path().join("source/ghx");
let agent_home = dir.path().join("agent");
fs::create_dir_all(&source).unwrap();
fs::write(
source.join(SKILL_ENTRYPOINT),
"---\nname: ghx\ndescription: local ghx\n---\nbody",
)
.unwrap();
install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Local {
path: source,
mode: SkillInstallMode::Copied,
},
)
.unwrap();
let installed = list_installed_skills(&agent_home).unwrap();
assert_eq!(installed[0].catalog.name, "ghx");
assert_eq!(installed[0].install_mode, "copied");
}
#[test]
fn copy_mode_rejects_symlinks_inside_skill_directory() {
let dir = tempdir().unwrap();
let source = dir.path().join("source/demo");
let agent_home = dir.path().join("agent");
fs::create_dir_all(&source).unwrap();
fs::write(source.join(SKILL_ENTRYPOINT), "# Demo").unwrap();
let target = dir.path().join("target.txt");
fs::write(&target, "target").unwrap();
create_symlink(&target, &source.join("linked.txt")).unwrap();
let error = install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Local {
path: source,
mode: SkillInstallMode::Copied,
},
)
.unwrap_err();
assert!(format!("{error:?}").contains("does not support symlinks"));
assert!(!agent_home.join("skills/demo").exists());
}
#[test]
fn list_installed_skills_reports_builtin_mode() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Builtin { name: "ghx".into() },
)
.unwrap();
let installed = list_installed_skills(&agent_home).unwrap();
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].catalog.name, "ghx");
assert_eq!(installed[0].install_mode, "builtin");
assert!(installed[0].link_target.is_none());
assert!(installed[0].warning.is_none());
}
#[test]
fn list_installed_skills_merges_compatible_agent_roots() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
for (root, name) in [("skills", "alpha"), (".codex/skills", "beta")] {
let skill_dir = agent_home.join(root).join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join(SKILL_ENTRYPOINT),
format!("---\nname: {name}\ndescription: installed\n---\nbody"),
)
.unwrap();
}
let installed = list_installed_skills(&agent_home).unwrap();
let names = installed
.iter()
.map(|skill| skill.catalog.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn install_existing_destination_returns_structured_conflict() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
let destination = agent_home.join("skills/ghx");
fs::create_dir_all(&destination).unwrap();
fs::write(destination.join(SKILL_ENTRYPOINT), "# Existing ghx").unwrap();
let error = install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Builtin { name: "ghx".into() },
)
.unwrap_err();
let conflict = error
.downcast_ref::<SkillInstallConflict>()
.expect("existing destination should return a structured conflict");
assert_eq!(conflict.skill_name, "ghx");
assert_eq!(conflict.destination, destination);
}
#[test]
fn install_existing_broken_symlink_returns_structured_conflict() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
let skills_root = agent_home.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let destination = skills_root.join("demo");
create_symlink(&dir.path().join("missing-demo"), &destination).unwrap();
let source = dir.path().join("source/demo");
fs::create_dir_all(&source).unwrap();
fs::write(source.join(SKILL_ENTRYPOINT), "# Demo").unwrap();
let error = install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Local {
path: source,
mode: SkillInstallMode::Linked,
},
)
.unwrap_err();
let conflict = error
.downcast_ref::<SkillInstallConflict>()
.expect("dangling destination symlink should return a structured conflict");
assert_eq!(conflict.skill_name, "demo");
assert_eq!(conflict.destination, destination);
}
#[test]
fn uninstall_linked_skill_removes_only_agent_local_link() {
let dir = tempdir().unwrap();
let source = dir.path().join("source/demo");
let agent_home = dir.path().join("agent");
fs::create_dir_all(&source).unwrap();
fs::write(source.join(SKILL_ENTRYPOINT), "# Demo").unwrap();
install_skill_with_user_home(
&agent_home,
None,
&crate::types::SkillInstallKind::Local {
path: source.clone(),
mode: SkillInstallMode::Linked,
},
)
.unwrap();
uninstall_skill(&agent_home, "demo").unwrap();
assert!(source.join(SKILL_ENTRYPOINT).is_file());
assert!(!agent_home.join("skills/demo").exists());
}
#[test]
fn uninstall_skill_searches_compatible_agent_roots() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
let source = dir.path().join("source/demo");
let legacy_destination = agent_home.join(".codex/skills/demo");
fs::create_dir_all(&source).unwrap();
fs::write(source.join(SKILL_ENTRYPOINT), "# Demo").unwrap();
fs::create_dir_all(legacy_destination.parent().unwrap()).unwrap();
create_symlink(&source, &legacy_destination).unwrap();
uninstall_skill(&agent_home, "demo").unwrap();
assert!(source.join(SKILL_ENTRYPOINT).is_file());
assert!(!legacy_destination.exists());
}
#[test]
fn uninstall_skill_rejects_duplicate_installs_across_roots() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
for root in ["skills", ".codex/skills"] {
let skill_dir = agent_home.join(root).join("demo");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join(SKILL_ENTRYPOINT), "# Demo").unwrap();
}
let error = uninstall_skill(&agent_home, "demo").unwrap_err();
assert!(error.to_string().contains("multiple agent skill roots"));
assert!(agent_home.join("skills/demo").exists());
assert!(agent_home.join(".codex/skills/demo").exists());
}
#[test]
fn list_installed_skills_reports_link_mode_and_broken_link_warning() {
let dir = tempdir().unwrap();
let agent_home = dir.path().join("agent");
let skills_root = agent_home.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let missing = dir.path().join("missing-skill");
create_symlink(&missing, &skills_root.join("missing")).unwrap();
let installed = list_installed_skills(&agent_home).unwrap();
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].catalog.name, "missing");
assert_eq!(installed[0].install_mode, "linked");
assert_eq!(installed[0].link_target.as_deref(), Some(missing.as_path()));
assert!(installed[0]
.warning
.as_deref()
.unwrap_or_default()
.contains("link target does not exist"));
}
#[test]
fn named_install_prefers_agents_user_global_skill_over_compat_roots() {
let dir = tempdir().unwrap();
let user_home = dir.path().join("user");
let agent_home = dir.path().join("agent");
for (root, description) in [(".agents/skills", "preferred"), (".codex/skills", "compat")] {
let source = user_home.join(root).join("demo");
fs::create_dir_all(&source).unwrap();
fs::write(
source.join(SKILL_ENTRYPOINT),
format!("---\nname: demo\ndescription: {description}\n---\nbody"),
)
.unwrap();
}
let installed = install_skill_with_user_home(
&agent_home,
Some(&user_home),
&crate::types::SkillInstallKind::Named {
name: "demo".into(),
mode: SkillInstallMode::Linked,
},
)
.unwrap();
assert_eq!(installed, "demo");
assert_eq!(
fs::read_link(agent_home.join("skills/demo")).unwrap(),
user_home.join(".agents/skills/demo")
);
}
#[test]
fn remote_install_requires_skill_manager() {
let dir = tempdir().unwrap();
let error = install_skill_with_user_home_and_remote_installer(
&dir.path().join("agent"),
Some(&dir.path().join("user")),
&crate::types::SkillInstallKind::Remote {
package: "vercel-labs/agent-skills".into(),
skill: Some("demo".into()),
mode: SkillInstallMode::Linked,
},
&UnavailableRemoteSkillInstaller,
)
.unwrap_err();
let unavailable = error
.downcast_ref::<SkillManagerUnavailable>()
.expect("missing remote manager should be structured");
assert_eq!(unavailable.manager, "npx");
}
#[test]
fn remote_package_validation_rejects_option_like_and_whitespace_refs() {
for package in [
"", " ", "--help", "-x", " demo", "demo ", "foo bar", "foo\nbar",
] {
assert!(
validate_remote_package(package).is_err(),
"package should be rejected: {package:?}"
);
}
}
#[test]
fn remote_package_validation_accepts_common_package_refs() {
for package in ["vercel-labs/agent-skills", "@scope/package", "agent-skills"] {
validate_remote_package(package).unwrap();
}
}
#[test]
fn command_output_with_timeout_captures_failed_process_output() {
let mut command = Command::new("sh");
command.args([
"-c",
"read ignored || true; printf captured-stdout; printf captured-stderr >&2; exit 7",
]);
let output = command_output_with_timeout(&mut command, Duration::from_secs(5))
.unwrap()
.expect("process should exit before timeout");
assert_eq!(output.status.code(), Some(7));
assert_eq!(bounded_output_excerpt(&output.stdout), "captured-stdout");
assert_eq!(bounded_output_excerpt(&output.stderr), "captured-stderr");
}
#[test]
fn remote_install_adds_global_skill_non_interactively_then_links_to_agent() {
let dir = tempdir().unwrap();
let user_home = dir.path().join("user");
let agent_home = dir.path().join("agent");
let source = user_home.join(".agents/skills/demo");
fs::create_dir_all(&source).unwrap();
fs::write(
source.join(SKILL_ENTRYPOINT),
"---\nname: demo\ndescription: remote\n---\nbody",
)
.unwrap();
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let installer = RecordingRemoteSkillInstaller {
calls: calls.clone(),
};
let installed = install_skill_with_user_home_and_remote_installer(
&agent_home,
Some(&user_home),
&crate::types::SkillInstallKind::Remote {
package: "vercel-labs/agent-skills".into(),
skill: Some("demo".into()),
mode: SkillInstallMode::Linked,
},
&installer,
)
.unwrap();
assert_eq!(installed, "demo");
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].package, "vercel-labs/agent-skills");
assert_eq!(calls[0].skill.as_deref(), Some("demo"));
assert_eq!(
fs::read_link(agent_home.join("skills/demo")).unwrap(),
user_home.join(".agents/skills/demo")
);
}
}