use serde::Serialize;
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,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkillAgent {
Codex,
ClaudeCode,
Opencode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
Personal,
Project,
}
#[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_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_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>,
}
impl SkillError {
fn invalid_request(message: String, hint: Option<String>) -> Self {
Self { message, hint }
}
fn io(action: &str, err: std::io::Error) -> Self {
Self {
message: format!("{action} failed: {err}"),
hint: None,
}
}
}
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);
let mut installed = Vec::with_capacity(targets.len());
for target in &targets {
std::fs::create_dir_all(&target.skill_dir)
.map_err(|e| SkillError::io("create skill dir", e))?;
if let Some(kind) = skill_path_file_type(&target.skill_path)? {
if kind.is_symlink() && !options.force {
return Err(SkillError::invalid_request(
format!(
"refusing to overwrite symlinked skill at {}",
target.skill_path.display()
),
Some(
"pass --force to replace the symlink itself, or choose another --skills-dir"
.to_string(),
),
));
}
if !kind.is_symlink()
&& !is_managed_or_bundled_skill(spec, &target.skill_path)?
&& !options.force
{
return Err(SkillError::invalid_request(
format!(
"refusing to overwrite unmanaged skill at {}",
target.skill_path.display()
),
Some("pass --force to replace it, or choose another --skills-dir".to_string()),
));
}
}
write_skill_atomic(target, &content)?;
validate_installed_skill(spec, &target.skill_path)?;
installed.push(target_status(spec, target)?);
}
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)?;
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_symlink() && !options.force {
return Err(SkillError::invalid_request(
format!(
"refusing to remove symlinked skill at {}",
target.skill_path.display()
),
Some("pass --force to remove the symlink itself".to_string()),
));
}
if !kind.is_symlink()
&& !is_managed_or_bundled_skill(spec, &target.skill_path)?
&& !options.force
{
return Err(SkillError::invalid_request(
format!(
"refusing to remove unmanaged skill at {}",
target.skill_path.display()
),
Some(format!(
"only skills generated by {} skill install can be removed without --force",
spec.marker_slug
)),
));
}
std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))?;
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,
})
}
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)?,
]),
(SkillAgentSelection::All, SkillScope::Project) => Ok(vec![
resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Project, None)?,
resolve_target(spec, SkillAgent::Opencode, SkillScope::Project, None)?,
]),
(SkillAgentSelection::Codex, SkillScope::Project) => Err(SkillError::invalid_request(
"Codex project skill scope is not supported".to_string(),
Some(
"use personal scope for Codex, or --agent claude-code/opencode --scope project"
.to_string(),
),
)),
(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(),
)?]),
}
}
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::Project) => Err(SkillError::invalid_request(
"Codex project skill scope is not supported".to_string(),
None,
)),
(SkillAgent::ClaudeCode, SkillScope::Personal) => {
Ok(home_dir()?.join(".claude").join("skills"))
}
(SkillAgent::ClaudeCode, SkillScope::Project) => project_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::Project) => project_skills_dir(".opencode"),
}
}
fn project_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_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 = normalize_skill_text(spec, &text) == normalize_skill_text(spec, spec.source);
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_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_path: target.skill_path.clone(),
removed,
}
}
fn generated_by(spec: &SkillSpec) -> String {
format!("Generated by {} skill install", spec.marker_slug)
}
fn source_hash(spec: &SkillSpec) -> String {
let mut hash = FNV1A64_OFFSET;
for byte in spec.source.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV1A64_PRIME);
}
format!("{hash:016x}")
}
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-source-hash-fnv1a64: {}\n-->",
generated_by(spec),
slug,
slug,
spec.name,
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_current_marker(spec, text)
|| normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
}
fn skill_text_has_current_marker(spec: &SkillSpec, text: &str) -> bool {
text.replace("\r\n", "\n")
.contains(&managed_marker_block(spec))
}
fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
let text = text
.replace("\r\n", "\n")
.replace(&managed_marker_block(spec), "");
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);
let tmp_path = target.skill_dir.join(format!(
".{SKILL_FILE_NAME}.{}.{}.tmp",
std::process::id(),
nanos
));
let result = (|| {
std::fs::write(&tmp_path, content)
.map_err(|e| SkillError::io("write temporary skill", e))?;
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);
}
result
}
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,
}
}
#[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-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 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 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);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn all_personal_resolves_three_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(), 3);
assert_eq!(targets[0].agent, SkillAgent::Codex);
assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
assert_eq!(targets[2].agent, SkillAgent::Opencode);
}
}
#[test]
fn all_project_skips_codex() {
let opts = SkillOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Project,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&spec(), &opts);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].agent, SkillAgent::ClaudeCode);
assert_eq!(targets[1].agent, SkillAgent::Opencode);
}
}
#[test]
fn codex_project_scope_is_rejected() {
let opts = SkillOptions {
agent: SkillAgentSelection::Codex,
scope: SkillScope::Project,
skills_dir: None,
force: false,
};
assert!(resolve_targets(&spec(), &opts).is_err());
}
}