use serde::Serialize;
use std::io::Write;
use std::path::{Path, PathBuf};
const SKILL_FILE_NAME: &str = "SKILL.md";
const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Clone, Copy, Debug)]
pub struct SkillSpec<'a> {
pub name: &'a str,
pub source: &'a str,
pub title: &'a str,
pub marker_slug: &'a str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SkillAgentSelection {
All,
Codex,
ClaudeCode,
Opencode,
Hermes,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkillAgent {
Codex,
ClaudeCode,
Opencode,
Hermes,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
Personal,
Workspace,
}
#[derive(Clone, Debug)]
pub struct SkillOptions {
pub agent: SkillAgentSelection,
pub scope: SkillScope,
pub skills_dir: Option<String>,
pub force: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SkillAction {
Status,
Install,
Uninstall,
}
#[derive(Clone, Debug, Serialize)]
pub struct SkillTargetStatus {
pub agent: SkillAgent,
pub scope: SkillScope,
pub skills_dir: PathBuf,
pub skill_dir: PathBuf,
pub skill_path: PathBuf,
pub installed: bool,
pub managed: bool,
pub valid: bool,
pub current: bool,
pub validation_error: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct SkillUninstallStatus {
pub agent: SkillAgent,
pub scope: SkillScope,
pub skills_dir: PathBuf,
pub skill_dir: PathBuf,
pub skill_path: PathBuf,
pub removed: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "code")]
pub enum SkillReport {
#[serde(rename = "skill_status")]
Status {
skill: String,
installed_all: bool,
valid_all: bool,
current_all: bool,
targets: Vec<SkillTargetStatus>,
},
#[serde(rename = "skill_install")]
Install {
skill: String,
installed: bool,
targets: Vec<SkillTargetStatus>,
hint: &'static str,
},
#[serde(rename = "skill_uninstall")]
Uninstall {
skill: String,
removed_any: bool,
targets: Vec<SkillUninstallStatus>,
},
}
#[derive(Clone, Debug)]
pub struct SkillError {
pub message: String,
pub hint: Option<String>,
pub partial_report: Option<SkillReport>,
}
impl SkillError {
fn invalid_request(message: String, hint: Option<String>) -> Self {
Self {
message,
hint,
partial_report: None,
}
}
fn io(action: &str, err: std::io::Error) -> Self {
Self {
message: format!("{action} failed: {err}"),
hint: None,
partial_report: None,
}
}
fn with_partial_report(mut self, report: SkillReport) -> Self {
self.partial_report = Some(report);
self
}
}
pub fn run_skill_admin(
spec: &SkillSpec,
action: SkillAction,
options: &SkillOptions,
) -> Result<SkillReport, SkillError> {
validate_spec(spec)?;
match action {
SkillAction::Status => status(spec, options),
SkillAction::Install => install(spec, options),
SkillAction::Uninstall => uninstall(spec, options),
}
}
fn status(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
let targets = resolve_targets(spec, options)?;
let mut statuses = Vec::with_capacity(targets.len());
for target in &targets {
statuses.push(target_status(spec, target)?);
}
Ok(SkillReport::Status {
skill: spec.name.to_string(),
installed_all: statuses.iter().all(|s| s.installed),
valid_all: statuses.iter().all(|s| s.valid),
current_all: statuses.iter().all(|s| s.current),
targets: statuses,
})
}
fn install(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
validate_skill_text(spec, spec.source)?;
let targets = resolve_targets(spec, options)?;
let content = managed_skill_contents(spec);
preflight_install_targets(spec, options, &targets)?;
for target in &targets {
if let Err(err) = std::fs::create_dir_all(&target.skill_dir)
.map_err(|e| SkillError::io("create skill dir", e))
{
return Err(err.with_partial_report(install_report_lossy(spec, &targets, false)));
}
}
install_targets(spec, &targets, &content)
}
fn preflight_install_targets(
spec: &SkillSpec,
options: &SkillOptions,
targets: &[SkillTarget],
) -> Result<(), SkillError> {
let mut failures = Vec::new();
for target in targets {
if let Some(kind) = skill_path_file_type(&target.skill_path)? {
if kind.is_symlink() {
if !options.force {
failures.push(format!(
"refusing to overwrite symlinked skill at {}",
target.skill_path.display()
));
}
continue;
}
if !kind.is_file() {
failures.push(format!(
"refusing to overwrite non-regular skill at {}",
target.skill_path.display()
));
continue;
}
if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
failures.push(format!(
"refusing to overwrite unmanaged skill at {}",
target.skill_path.display()
));
}
}
}
if failures.is_empty() {
return Ok(());
}
Err(SkillError::invalid_request(
failures.join("; "),
Some("pass --force to replace unmanaged files or symlinks".to_string()),
)
.with_partial_report(install_report_lossy(spec, targets, false)))
}
fn install_targets(
spec: &SkillSpec,
targets: &[SkillTarget],
content: &str,
) -> Result<SkillReport, SkillError> {
let mut installed = Vec::with_capacity(targets.len());
for target in targets {
if let Err(err) = write_skill_atomic(target, content) {
return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
}
if let Err(err) = validate_installed_skill(spec, &target.skill_path) {
return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
}
match target_status(spec, target) {
Ok(status) => installed.push(status),
Err(err) => {
return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
}
}
}
Ok(SkillReport::Install {
skill: spec.name.to_string(),
installed: true,
targets: installed,
hint: "restart the agent so it reloads installed skills",
})
}
fn uninstall(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
let targets = resolve_targets(spec, options)?;
preflight_uninstall_targets(spec, options, &targets)?;
let mut removed = Vec::with_capacity(targets.len());
for target in &targets {
let Some(kind) = skill_path_file_type(&target.skill_path)? else {
removed.push(target_uninstall_status(target, false));
continue;
};
if !kind.is_file() && !kind.is_symlink() {
let err = SkillError::invalid_request(
format!(
"refusing to remove non-regular skill at {}",
target.skill_path.display()
),
None,
);
return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
}
if let Err(err) =
std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))
{
return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
}
let _ = std::fs::remove_dir(&target.skill_dir);
removed.push(target_uninstall_status(target, true));
}
Ok(SkillReport::Uninstall {
skill: spec.name.to_string(),
removed_any: removed.iter().any(|s| s.removed),
targets: removed,
})
}
fn preflight_uninstall_targets(
spec: &SkillSpec,
options: &SkillOptions,
targets: &[SkillTarget],
) -> Result<(), SkillError> {
let mut failures = Vec::new();
for target in targets {
let Some(kind) = skill_path_file_type(&target.skill_path)? else {
continue;
};
if kind.is_symlink() {
if !options.force {
failures.push(format!(
"refusing to remove symlinked skill at {}",
target.skill_path.display()
));
}
continue;
}
if !kind.is_file() {
failures.push(format!(
"refusing to remove non-regular skill at {}",
target.skill_path.display()
));
continue;
}
if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
failures.push(format!(
"refusing to remove unmanaged skill at {}",
target.skill_path.display()
));
}
}
if failures.is_empty() {
return Ok(());
}
Err(SkillError::invalid_request(
failures.join("; "),
Some(format!(
"only skills generated by {} skill install can be removed without --force",
spec.marker_slug
)),
)
.with_partial_report(uninstall_report_lossy(spec, targets, &[])))
}
struct SkillTarget {
agent: SkillAgent,
scope: SkillScope,
skills_dir: PathBuf,
skill_dir: PathBuf,
skill_path: PathBuf,
}
fn resolve_targets(
spec: &SkillSpec,
options: &SkillOptions,
) -> Result<Vec<SkillTarget>, SkillError> {
if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
return Err(SkillError::invalid_request(
"--skills-dir requires a single --agent".to_string(),
Some("custom skills directories are ambiguous when --agent all is used".to_string()),
));
}
match (options.agent, options.scope) {
(SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
resolve_target(spec, SkillAgent::Codex, SkillScope::Personal, None)?,
resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
resolve_target(spec, SkillAgent::Opencode, SkillScope::Personal, None)?,
resolve_target(spec, SkillAgent::Hermes, SkillScope::Personal, None)?,
]),
(SkillAgentSelection::All, SkillScope::Workspace) => Ok(vec![
resolve_target(spec, SkillAgent::Codex, SkillScope::Workspace, None)?,
resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Workspace, None)?,
resolve_target(spec, SkillAgent::Opencode, SkillScope::Workspace, None)?,
resolve_target(spec, SkillAgent::Hermes, SkillScope::Workspace, None)?,
]),
(SkillAgentSelection::Codex, SkillScope::Workspace) => Ok(vec![resolve_target(
spec,
SkillAgent::Codex,
SkillScope::Workspace,
options.skills_dir.as_deref(),
)?]),
(SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
spec,
SkillAgent::Codex,
SkillScope::Personal,
options.skills_dir.as_deref(),
)?]),
(SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
spec,
SkillAgent::ClaudeCode,
scope,
options.skills_dir.as_deref(),
)?]),
(SkillAgentSelection::Opencode, scope) => Ok(vec![resolve_target(
spec,
SkillAgent::Opencode,
scope,
options.skills_dir.as_deref(),
)?]),
(SkillAgentSelection::Hermes, scope) => Ok(vec![resolve_target(
spec,
SkillAgent::Hermes,
scope,
options.skills_dir.as_deref(),
)?]),
}
}
fn resolve_target(
spec: &SkillSpec,
agent: SkillAgent,
scope: SkillScope,
skills_dir: Option<&str>,
) -> Result<SkillTarget, SkillError> {
let skills_dir = match skills_dir {
Some(dir) => expand_tilde(dir)?,
None => default_skills_dir(agent, scope)?,
};
let skill_dir = skills_dir.join(spec.name);
let skill_path = skill_dir.join(SKILL_FILE_NAME);
Ok(SkillTarget {
agent,
scope,
skills_dir,
skill_dir,
skill_path,
})
}
fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, SkillError> {
match (agent, scope) {
(SkillAgent::Codex, SkillScope::Personal) => {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
Ok(PathBuf::from(codex_home).join("skills"))
} else {
Ok(home_dir()?.join(".codex").join("skills"))
}
}
(SkillAgent::Codex, SkillScope::Workspace) => workspace_skills_dir(".codex"),
(SkillAgent::ClaudeCode, SkillScope::Personal) => {
Ok(home_dir()?.join(".claude").join("skills"))
}
(SkillAgent::ClaudeCode, SkillScope::Workspace) => workspace_skills_dir(".claude"),
(SkillAgent::Opencode, SkillScope::Personal) => {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
Ok(PathBuf::from(xdg).join("opencode").join("skills"))
} else {
Ok(home_dir()?.join(".config").join("opencode").join("skills"))
}
}
(SkillAgent::Opencode, SkillScope::Workspace) => workspace_skills_dir(".opencode"),
(SkillAgent::Hermes, SkillScope::Personal) => {
if let Some(hermes_home) = std::env::var_os("HERMES_HOME") {
Ok(PathBuf::from(hermes_home).join("skills"))
} else {
Ok(home_dir()?.join(".hermes").join("skills"))
}
}
(SkillAgent::Hermes, SkillScope::Workspace) => workspace_skills_dir(".hermes"),
}
}
fn workspace_skills_dir(agent_dir: &str) -> Result<PathBuf, SkillError> {
std::env::current_dir()
.map(|dir| dir.join(agent_dir).join("skills"))
.map_err(|e| SkillError::io("resolve current directory", e))
}
fn target_status(spec: &SkillSpec, target: &SkillTarget) -> Result<SkillTargetStatus, SkillError> {
let Some(kind) = skill_path_file_type(&target.skill_path)? else {
return Ok(SkillTargetStatus {
agent: target.agent,
scope: target.scope,
skills_dir: target.skills_dir.clone(),
skill_dir: target.skill_dir.clone(),
skill_path: target.skill_path.clone(),
installed: false,
managed: false,
valid: false,
current: false,
validation_error: None,
});
};
let installed = true;
let mut valid = false;
let mut current = false;
let mut validation_error = None;
let mut managed = false;
if kind.is_symlink() {
validation_error = Some("target SKILL.md is a symlink; refusing to follow it".to_string());
} else if kind.is_file() {
let text = std::fs::read_to_string(&target.skill_path)
.map_err(|e| SkillError::io("read skill", e))?;
managed = skill_text_is_managed_or_bundled(spec, &text);
current = normalized_content_hash(spec, &text) == source_hash(spec);
match validate_skill_text(spec, &text) {
Ok(()) => valid = true,
Err(err) => validation_error = Some(err.message),
}
} else {
validation_error = Some("target SKILL.md is not a regular file".to_string());
}
Ok(SkillTargetStatus {
agent: target.agent,
scope: target.scope,
skills_dir: target.skills_dir.clone(),
skill_dir: target.skill_dir.clone(),
skill_path: target.skill_path.clone(),
installed,
managed,
valid,
current,
validation_error,
})
}
fn target_uninstall_status(target: &SkillTarget, removed: bool) -> SkillUninstallStatus {
SkillUninstallStatus {
agent: target.agent,
scope: target.scope,
skills_dir: target.skills_dir.clone(),
skill_dir: target.skill_dir.clone(),
skill_path: target.skill_path.clone(),
removed,
}
}
fn target_status_lossy(spec: &SkillSpec, target: &SkillTarget) -> SkillTargetStatus {
target_status(spec, target).unwrap_or_else(|err| {
let installed = std::fs::symlink_metadata(&target.skill_path).is_ok();
SkillTargetStatus {
agent: target.agent,
scope: target.scope,
skills_dir: target.skills_dir.clone(),
skill_dir: target.skill_dir.clone(),
skill_path: target.skill_path.clone(),
installed,
managed: false,
valid: false,
current: false,
validation_error: Some(err.message),
}
})
}
fn install_report_lossy(spec: &SkillSpec, targets: &[SkillTarget], installed: bool) -> SkillReport {
SkillReport::Install {
skill: spec.name.to_string(),
installed,
targets: targets
.iter()
.map(|target| target_status_lossy(spec, target))
.collect(),
hint: "restart the agent so it reloads installed skills",
}
}
fn uninstall_report_lossy(
spec: &SkillSpec,
targets: &[SkillTarget],
removed: &[SkillUninstallStatus],
) -> SkillReport {
let mut statuses = Vec::with_capacity(targets.len());
for target in targets {
if let Some(status) = removed.iter().find(|status| {
status.agent == target.agent
&& status.scope == target.scope
&& status.skill_path == target.skill_path
}) {
statuses.push(status.clone());
} else {
statuses.push(target_uninstall_status(target, false));
}
}
SkillReport::Uninstall {
skill: spec.name.to_string(),
removed_any: statuses.iter().any(|status| status.removed),
targets: statuses,
}
}
fn generated_by(spec: &SkillSpec) -> String {
format!("Generated by {} skill install", spec.marker_slug)
}
fn text_hash(text: &str) -> String {
let mut hash = FNV1A64_OFFSET;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV1A64_PRIME);
}
format!("{hash:016x}")
}
fn source_hash(spec: &SkillSpec) -> String {
normalized_content_hash(spec, spec.source)
}
fn normalized_content_hash(spec: &SkillSpec, text: &str) -> String {
text_hash(&normalize_skill_text(spec, text))
}
fn managed_marker_block(spec: &SkillSpec) -> String {
let slug = spec.marker_slug;
format!(
"<!--\n{}\n{}-managed-skill: true\n{}-managed-skill-name: {}\n{}-managed-skill-owner: {}\n{}-managed-skill-content-hash-fnv1a64: {}\n-->",
generated_by(spec),
slug,
slug,
spec.name,
slug,
slug,
slug,
source_hash(spec)
)
}
fn managed_skill_contents(spec: &SkillSpec) -> String {
let block = managed_marker_block(spec);
let mut lines = spec.source.lines();
let mut output = String::new();
let mut inserted = false;
if let Some(first) = lines.next() {
output.push_str(first);
output.push('\n');
}
for line in lines {
output.push_str(line);
output.push('\n');
if !inserted && line.trim() == "---" {
output.push_str(&block);
output.push_str("\n\n");
inserted = true;
}
}
if !inserted {
output.push_str(&block);
output.push('\n');
}
output
}
fn validate_installed_skill(spec: &SkillSpec, path: &Path) -> Result<(), SkillError> {
let text =
std::fs::read_to_string(path).map_err(|e| SkillError::io("read installed skill", e))?;
validate_skill_text(spec, &text)
}
fn validate_skill_text(spec: &SkillSpec, text: &str) -> Result<(), SkillError> {
let frontmatter = validate_skill_frontmatter(text).map_err(|err| {
SkillError::invalid_request(
format!("invalid {} skill front matter: {err}", spec.title),
Some("quote scalar values that contain ': ', especially description".to_string()),
)
})?;
if frontmatter.name != spec.name {
return Err(SkillError::invalid_request(
format!(
"invalid {} skill front matter: name {:?} does not match expected {:?}",
spec.title, frontmatter.name, spec.name
),
Some(format!("set front matter name to {}", spec.name)),
));
}
Ok(())
}
struct SkillFrontmatter {
name: String,
}
fn validate_skill_frontmatter(text: &str) -> Result<SkillFrontmatter, String> {
let mut lines = text.lines().enumerate();
let Some((_, first)) = lines.next() else {
return Err("missing YAML front matter".to_string());
};
if first.trim() != "---" {
return Err("missing opening --- YAML front matter delimiter".to_string());
}
let mut found_end = false;
let mut name = None;
let mut has_description = false;
for (idx, line) in lines {
let line_no = idx + 1;
let trimmed = line.trim();
if trimmed == "---" {
found_end = true;
break;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') {
return Err(format!("line {line_no}: nested YAML is not supported here"));
}
let Some((key, value)) = line.split_once(':') else {
return Err(format!("line {line_no}: expected key: value"));
};
let key = key.trim();
if key.is_empty() {
return Err(format!("line {line_no}: empty key"));
}
let value = value.trim_start();
if key == "name" {
name = Some(parse_frontmatter_scalar(value));
}
if key == "description" {
has_description = true;
}
if value.starts_with('"') || value.starts_with('\'') {
continue;
}
if value.contains(": ") {
return Err(format!(
"line {line_no}: unquoted scalar contains ': '; quote the value"
));
}
}
if !found_end {
return Err("missing closing --- YAML front matter delimiter".to_string());
}
let Some(name) = name else {
return Err("missing required name field".to_string());
};
if !has_description {
return Err("missing required description field".to_string());
}
Ok(SkillFrontmatter { name })
}
fn parse_frontmatter_scalar(value: &str) -> String {
let value = value.trim();
if value.len() >= 2 {
let bytes = value.as_bytes();
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
{
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
fn is_managed_or_bundled_skill(spec: &SkillSpec, path: &Path) -> Result<bool, SkillError> {
let Some(kind) = skill_path_file_type(path)? else {
return Ok(false);
};
if kind.is_symlink() {
return Err(SkillError::invalid_request(
format!("refusing to inspect symlinked skill at {}", path.display()),
Some("pass --force to replace or remove the symlink itself".to_string()),
));
}
let text = std::fs::read_to_string(path).map_err(|e| SkillError::io("read skill", e))?;
Ok(skill_text_is_managed_or_bundled(spec, &text))
}
fn skill_text_is_managed_or_bundled(spec: &SkillSpec, text: &str) -> bool {
skill_text_has_managed_identity(spec, text)
|| normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
}
fn skill_text_has_managed_identity(spec: &SkillSpec, text: &str) -> bool {
for block in html_comment_blocks(text) {
if managed_marker_block_has_identity(spec, &block) {
return true;
}
}
false
}
fn managed_marker_block_has_identity(spec: &SkillSpec, block: &str) -> bool {
let slug = spec.marker_slug;
let generated_by_line = generated_by(spec);
let managed_line = format!("{slug}-managed-skill: true");
let name_line = format!("{slug}-managed-skill-name: {}", spec.name);
let owner_line = format!("{slug}-managed-skill-owner: {slug}");
let mut has_generated_by = false;
let mut has_managed = false;
let mut has_name = false;
let mut has_owner = false;
for line in block.replace("\r\n", "\n").lines() {
let trimmed = line.trim();
has_generated_by |= trimmed == generated_by_line;
has_managed |= trimmed == managed_line;
has_name |= trimmed == name_line;
has_owner |= trimmed == owner_line;
}
has_generated_by && has_managed && has_name && has_owner
}
fn html_comment_blocks(text: &str) -> Vec<String> {
let normalized = text.replace("\r\n", "\n");
let mut blocks = Vec::new();
let mut lines = normalized.lines();
while let Some(line) = lines.next() {
if line.trim() != "<!--" {
continue;
}
let mut block = vec![line.to_string()];
for next in lines.by_ref() {
block.push(next.to_string());
if next.trim() == "-->" {
blocks.push(block.join("\n"));
break;
}
}
}
blocks
}
fn strip_managed_marker_blocks(spec: &SkillSpec, text: &str) -> String {
let normalized = text.replace("\r\n", "\n");
let mut output = Vec::new();
let mut lines = normalized.lines();
while let Some(line) = lines.next() {
if line.trim() != "<!--" {
output.push(line.to_string());
continue;
}
let mut block = vec![line.to_string()];
let mut closed = false;
for next in lines.by_ref() {
block.push(next.to_string());
if next.trim() == "-->" {
closed = true;
break;
}
}
if closed {
let block_text = block.join("\n");
if managed_marker_block_has_identity(spec, &block_text) {
continue;
}
}
output.extend(block);
}
output.join("\n")
}
fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
let text = strip_managed_marker_blocks(spec, text);
let mut out: Vec<&str> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() && out.last().is_some_and(|prev| prev.trim().is_empty()) {
continue;
}
out.push(line);
}
out.join("\n").trim().to_string()
}
fn validate_spec(spec: &SkillSpec) -> Result<(), SkillError> {
validate_slug("skill name", spec.name)?;
validate_slug("marker slug", spec.marker_slug)?;
validate_skill_text(spec, spec.source)
}
fn validate_slug(field: &str, value: &str) -> Result<(), SkillError> {
if slug_is_valid(value) {
return Ok(());
}
Err(SkillError::invalid_request(
format!(
"invalid {field} {value:?}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]"
),
Some("use lowercase ASCII letters, digits, and single hyphen-separated words".to_string()),
))
}
fn slug_is_valid(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.is_empty() {
return false;
}
fn is_lower_alnum(byte: u8) -> bool {
byte.is_ascii_lowercase() || byte.is_ascii_digit()
}
if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
return false;
}
bytes
.iter()
.all(|byte| is_lower_alnum(*byte) || *byte == b'-')
}
fn skill_path_file_type(path: &Path) -> Result<Option<std::fs::FileType>, SkillError> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => Ok(Some(metadata.file_type())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(SkillError::io("inspect skill path", err)),
}
}
fn write_skill_atomic(target: &SkillTarget, content: &str) -> Result<(), SkillError> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
for attempt in 0..16 {
let tmp_path = target.skill_dir.join(format!(
".{SKILL_FILE_NAME}.{}.{}.{}.tmp",
std::process::id(),
nanos,
attempt
));
let mut tmp = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
{
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(SkillError::io("create temporary skill", err)),
};
let result = (|| {
tmp.write_all(content.as_bytes())
.map_err(|e| SkillError::io("write temporary skill", e))?;
tmp.sync_all()
.map_err(|e| SkillError::io("sync temporary skill", e))?;
drop(tmp);
std::fs::rename(&tmp_path, &target.skill_path)
.map_err(|e| SkillError::io("replace skill", e))?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
return result;
}
Err(SkillError::io(
"create temporary skill",
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"temporary skill path collision",
),
))
}
fn home_dir() -> Result<PathBuf, SkillError> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| {
SkillError::invalid_request(
"cannot determine home directory".to_string(),
Some("pass --skills-dir explicitly".to_string()),
)
})
}
fn expand_tilde(input: &str) -> Result<PathBuf, SkillError> {
if input == "~" {
return home_dir();
}
if let Some(rest) = input.strip_prefix("~/") {
return Ok(home_dir()?.join(rest));
}
Ok(PathBuf::from(input))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
const SKILL_SOURCE: &str =
"---\nname: agent-first-test\ndescription: test skill\n---\n\n# Body\n\nrules.\n";
fn spec() -> SkillSpec<'static> {
SkillSpec {
name: "agent-first-test",
source: SKILL_SOURCE,
title: "Agent-First Test",
marker_slug: "aftest",
}
}
fn managed_skill_with_body(body: &str) -> String {
format!(
"---\nname: agent-first-test\ndescription: test skill\n---\n{}\n\n{body}",
managed_marker_block(&spec())
)
}
fn temp_skills_dir(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"afdata_skill_{name}_{}_{}",
std::process::id(),
suffix
))
}
fn options(agent: SkillAgentSelection, dir: &Path, force: bool) -> SkillOptions {
SkillOptions {
agent,
scope: SkillScope::Personal,
skills_dir: Some(dir.to_string_lossy().to_string()),
force,
}
}
fn custom_target(agent: SkillAgent, dir: &Path) -> SkillTarget {
let skill_dir = dir.join("agent-first-test");
SkillTarget {
agent,
scope: SkillScope::Personal,
skills_dir: dir.to_path_buf(),
skill_path: skill_dir.join(SKILL_FILE_NAME),
skill_dir,
}
}
#[test]
fn validates_bundled_frontmatter() {
assert!(validate_skill_frontmatter(SKILL_SOURCE).is_ok());
}
#[test]
fn rejects_unquoted_colon_space() {
let bad = "---\nname: x\ndescription: broken: yaml\n---\n";
assert!(validate_skill_frontmatter(bad).is_err());
}
fn install_status_uninstall_for(agent: SkillAgentSelection, expect: SkillAgent, tag: &str) {
let dir = temp_skills_dir(tag);
let opts = options(agent, &dir, false);
let skill_path = dir.join("agent-first-test").join(SKILL_FILE_NAME);
let installed = run_skill_admin(&spec(), SkillAction::Install, &opts);
assert!(installed.is_ok());
assert!(skill_path.is_file());
let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
assert!(text.contains(&managed_marker_block(&spec())));
assert!(text.contains("aftest-managed-skill-name: agent-first-test"));
assert!(text.contains("aftest-managed-skill-owner: aftest"));
assert!(text.contains("aftest-managed-skill-content-hash-fnv1a64:"));
assert!(!text.contains("aftest-managed-skill-source-hash-fnv1a64:"));
let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
assert!(status.is_ok());
if let Ok(SkillReport::Status {
installed_all,
valid_all,
current_all,
targets,
..
}) = status
{
assert!(installed_all);
assert!(valid_all);
assert!(current_all);
assert_eq!(targets.first().map(|t| t.agent), Some(expect));
assert_eq!(targets.first().map(|t| t.current), Some(true));
}
let removed = run_skill_admin(&spec(), SkillAction::Uninstall, &opts);
assert!(removed.is_ok());
assert!(!skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn install_status_uninstall_codex() {
install_status_uninstall_for(SkillAgentSelection::Codex, SkillAgent::Codex, "codex");
}
#[test]
fn install_status_uninstall_claude_code() {
install_status_uninstall_for(
SkillAgentSelection::ClaudeCode,
SkillAgent::ClaudeCode,
"claude",
);
}
#[test]
fn install_status_uninstall_opencode() {
install_status_uninstall_for(
SkillAgentSelection::Opencode,
SkillAgent::Opencode,
"opencode",
);
}
#[test]
fn install_status_uninstall_hermes() {
install_status_uninstall_for(SkillAgentSelection::Hermes, SkillAgent::Hermes, "hermes");
}
#[test]
fn status_reports_stale_install_as_not_current() {
let dir = temp_skills_dir("stale");
let opts = options(SkillAgentSelection::Opencode, &dir, false);
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
let stale = managed_skill_with_body("# Body\n\nOLD rules.\n");
assert!(std::fs::write(&skill_path, stale).is_ok());
let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
if let Ok(SkillReport::Status {
current_all,
targets,
..
}) = status
{
assert!(!current_all);
if let Some(t) = targets.first() {
assert!(t.installed);
assert!(t.valid);
assert!(t.managed);
assert!(!t.current);
}
}
assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_ok());
let refreshed = std::fs::read_to_string(&skill_path).unwrap_or_default();
assert!(refreshed.contains(&managed_marker_block(&spec())));
assert!(!refreshed.contains("<!-- aftest-managed-skill: true -->"));
if let Ok(SkillReport::Status { targets, .. }) =
run_skill_admin(&spec(), SkillAction::Status, &opts)
{
assert_eq!(targets.first().map(|t| t.current), Some(true));
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn random_text_with_marker_words_is_not_managed() {
let dir = temp_skills_dir("marker-words");
let opts = options(SkillAgentSelection::Opencode, &dir, false);
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
let random = format!(
"---\nname: agent-first-test\ndescription: test skill\n---\n\nThis mentions {} and {} but is not a generated block.\n",
generated_by(&spec()),
"aftest-managed-skill: true"
);
assert!(std::fs::write(&skill_path, random).is_ok());
if let Ok(SkillReport::Status { targets, .. }) =
run_skill_admin(&spec(), SkillAction::Status, &opts)
{
assert_eq!(targets.first().map(|t| t.managed), Some(false));
}
assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn old_marker_format_is_not_managed() {
let dir = temp_skills_dir("old-marker");
let opts = options(SkillAgentSelection::Opencode, &dir, false);
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
let old_marker = concat!(
"---\n",
"name: agent-first-test\n",
"description: test skill\n",
"---\n",
"<!--\n",
"Generated by aftest skill install\n",
"aftest-managed-skill: true\n",
"aftest-managed-skill-name: agent-first-test\n",
"aftest-managed-skill-source-hash-fnv1a64: deadbeef\n",
"-->\n",
"\n",
"# Body\n",
"\n",
"rules.\n"
);
assert!(std::fs::write(&skill_path, old_marker).is_ok());
if let Ok(SkillReport::Status { targets, .. }) =
run_skill_admin(&spec(), SkillAction::Status, &opts)
&& let Some(t) = targets.first()
{
assert!(t.installed);
assert!(t.valid);
assert!(!t.managed);
assert!(!t.current);
}
assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn install_preflight_reports_all_targets_without_writing() {
let dir = temp_skills_dir("install-preflight");
let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
assert!(
std::fs::write(
&opencode.skill_path,
"---\nname: custom\ndescription: custom\n---\n"
)
.is_ok()
);
let opts = SkillOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Personal,
skills_dir: None,
force: false,
};
let result = preflight_install_targets(&spec(), &opts, &[codex, opencode]);
assert!(result.is_err());
let Err(err) = result else {
return;
};
assert!(
err.message
.contains("refusing to overwrite unmanaged skill")
);
assert!(!dir.join("codex").join("agent-first-test").exists());
let partial_report = err.partial_report;
assert!(matches!(partial_report, Some(SkillReport::Install { .. })));
let Some(SkillReport::Install {
installed, targets, ..
}) = partial_report
else {
return;
};
assert!(!installed);
assert_eq!(targets.len(), 2);
assert_eq!(targets.first().map(|target| target.installed), Some(false));
assert_eq!(targets.get(1).map(|target| target.installed), Some(true));
assert_eq!(targets.get(1).map(|target| target.managed), Some(false));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn uninstall_preflight_reports_all_targets_without_removing() {
let dir = temp_skills_dir("uninstall-preflight");
let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
assert!(std::fs::create_dir_all(&codex.skill_dir).is_ok());
assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
assert!(std::fs::write(&codex.skill_path, managed_skill_contents(&spec())).is_ok());
assert!(
std::fs::write(
&opencode.skill_path,
"---\nname: custom\ndescription: custom\n---\n"
)
.is_ok()
);
let opts = SkillOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Personal,
skills_dir: None,
force: false,
};
let result = preflight_uninstall_targets(&spec(), &opts, &[codex, opencode]);
assert!(result.is_err());
let Err(err) = result else {
return;
};
assert!(err.message.contains("refusing to remove unmanaged skill"));
assert!(
dir.join("codex")
.join("agent-first-test")
.join(SKILL_FILE_NAME)
.exists()
);
let partial_report = err.partial_report;
assert!(matches!(
partial_report,
Some(SkillReport::Uninstall { .. })
));
let Some(SkillReport::Uninstall {
removed_any,
targets,
..
}) = partial_report
else {
return;
};
assert!(!removed_any);
assert_eq!(targets.len(), 2);
assert!(targets.iter().all(|target| !target.removed));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn install_and_uninstall_refuse_unmanaged() {
let dir = temp_skills_dir("unmanaged");
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
assert!(
std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
);
let opts = options(SkillAgentSelection::Codex, &dir, false);
assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
assert!(skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn invalid_spec_slugs_are_rejected_before_path_resolution() {
for name in ["", "../x", "x/y", ".hidden", "bad_name", "Bad"] {
let bad = SkillSpec {
name,
source: SKILL_SOURCE,
title: "Bad",
marker_slug: "aftest",
};
let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
assert!(
run_skill_admin(&bad, SkillAction::Status, &opts).is_err(),
"{name:?}"
);
}
let bad_marker = SkillSpec {
name: "agent-first-test",
source: SKILL_SOURCE,
title: "Bad",
marker_slug: "../aftest",
};
let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
assert!(run_skill_admin(&bad_marker, SkillAction::Status, &opts).is_err());
}
#[test]
fn frontmatter_name_must_match_spec_name() {
let bad = SkillSpec {
name: "agent-first-test",
source: "---\nname: other-skill\ndescription: test skill\n---\n",
title: "Bad",
marker_slug: "aftest",
};
let dir = temp_skills_dir("frontmatter-name");
let opts = options(SkillAgentSelection::Codex, &dir, false);
assert!(run_skill_admin(&bad, SkillAction::Install, &opts).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn symlink_target_is_rejected_by_default_and_force_does_not_follow() {
use std::os::unix::fs::symlink;
let dir = temp_skills_dir("symlink-install");
let opts = options(SkillAgentSelection::Codex, &dir, false);
let force_opts = options(SkillAgentSelection::Codex, &dir, true);
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
let external = dir.join("external.md");
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
assert!(std::fs::write(&external, "external").is_ok());
assert!(symlink(&external, &skill_path).is_ok());
assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
assert_eq!(
std::fs::read_to_string(&external).unwrap_or_default(),
"external"
);
assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
assert!(skill_path.is_symlink());
assert!(run_skill_admin(&spec(), SkillAction::Install, &force_opts).is_ok());
assert_eq!(
std::fs::read_to_string(&external).unwrap_or_default(),
"external"
);
assert!(skill_path.is_file());
assert!(!skill_path.is_symlink());
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn force_uninstall_removes_symlink_without_following() {
use std::os::unix::fs::symlink;
let dir = temp_skills_dir("symlink-uninstall");
let force_opts = options(SkillAgentSelection::Codex, &dir, true);
let skill_dir = dir.join("agent-first-test");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
let external = dir.join("external.md");
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
assert!(std::fs::write(&external, "external").is_ok());
assert!(symlink(&external, &skill_path).is_ok());
assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &force_opts).is_ok());
assert!(!skill_path.exists());
assert_eq!(
std::fs::read_to_string(&external).unwrap_or_default(),
"external"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn serializes_to_protocol_shape() {
let dir = temp_skills_dir("serialize");
let opts = options(SkillAgentSelection::Opencode, &dir, false);
if let Ok(report) = run_skill_admin(&spec(), SkillAction::Install, &opts) {
let value = serde_json::to_value(&report).unwrap_or(serde_json::Value::Null);
assert_eq!(value["code"], "skill_install");
assert_eq!(value["installed"], true);
assert_eq!(value["targets"][0]["agent"], "opencode");
assert_eq!(value["targets"][0]["current"], true);
assert_eq!(
value["targets"][0]["skill_dir"],
serde_json::json!(dir.join("agent-first-test").to_string_lossy().to_string())
);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn all_personal_resolves_four_targets() {
let opts = SkillOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Personal,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&spec(), &opts);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 4);
assert_eq!(targets[0].agent, SkillAgent::Codex);
assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
assert_eq!(targets[2].agent, SkillAgent::Opencode);
assert_eq!(targets[3].agent, SkillAgent::Hermes);
}
}
#[test]
fn all_workspace_resolves_four_targets() {
let opts = SkillOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Workspace,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&spec(), &opts);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 4);
assert_eq!(targets[0].agent, SkillAgent::Codex);
assert_eq!(targets[0].scope, SkillScope::Workspace);
assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
assert_eq!(targets[1].scope, SkillScope::Workspace);
assert_eq!(targets[2].agent, SkillAgent::Opencode);
assert_eq!(targets[2].scope, SkillScope::Workspace);
assert_eq!(targets[3].agent, SkillAgent::Hermes);
assert_eq!(targets[3].scope, SkillScope::Workspace);
}
}
#[test]
fn codex_workspace_scope_uses_codex_skills_dir() {
let opts = SkillOptions {
agent: SkillAgentSelection::Codex,
scope: SkillScope::Workspace,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&spec(), &opts);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].agent, SkillAgent::Codex);
assert_eq!(targets[0].scope, SkillScope::Workspace);
assert!(targets[0].skills_dir.ends_with(".codex/skills"));
}
}
#[test]
fn hermes_workspace_scope_uses_hermes_skills_dir() {
let opts = SkillOptions {
agent: SkillAgentSelection::Hermes,
scope: SkillScope::Workspace,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&spec(), &opts);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].agent, SkillAgent::Hermes);
assert_eq!(targets[0].scope, SkillScope::Workspace);
assert!(targets[0].skills_dir.ends_with(".hermes/skills"));
}
}
}