use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use super::BackendState;
use super::confedit::{write_file_idem, yaml_scalar};
use super::report;
use crate::components::SkillDir;
use crate::error::{Error, IoContext, Result};
use crate::host::{Plugin, Scope};
const TAG_KEY: &str = "x-agentgear";
const SKILL_MD: &str = "SKILL.md";
pub(crate) fn agents_skills_root(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.agents/skills".into())),
Scope::Project { path } => Ok(path.join(".agents").join("skills")),
}
}
pub(crate) fn reconcile(root: &Path, plugin: &Plugin, skills: &[SkillDir]) -> Result<bool> {
if skills.is_empty() {
return Ok(false);
}
let tag = plugin.id();
let mut changed = false;
for skill in skills {
if slot_ownership(&root.join(&skill.name), &tag)? == Some(false) {
continue;
}
for (path, bytes) in skill_files(root, &tag, skill) {
changed |= write_file_idem(&path, &bytes)?;
}
}
Ok(changed)
}
pub(crate) fn remove(root: &Path, plugin: &Plugin, skills: &[SkillDir]) -> Result<bool> {
let tag = plugin.id();
let mut changed = false;
for skill in skills {
let dir = root.join(&skill.name);
if slot_ownership(&dir, &tag)? == Some(true) {
fs::remove_dir_all(&dir).io_ctx(|| format!("removing {}", dir.display()))?;
changed = true;
}
}
Ok(changed)
}
pub(crate) fn probe(root: &Path, plugin: &Plugin, skills: &[SkillDir]) -> Result<Option<BackendState>> {
if skills.is_empty() {
return Ok(None);
}
let tag = plugin.id();
let expected: Vec<(PathBuf, Vec<u8>)> =
skills.iter().map(|s| (root.join(&s.name).join(SKILL_MD), inject(&tag, &s.name, skill_md_source(s)))).collect();
report::probe_files(&expected, |_, existing| std::str::from_utf8(existing).is_ok_and(|t| frontmatter_has_tag(t, &tag)))
}
fn skill_files(root: &Path, tag: &str, skill: &SkillDir) -> Vec<(PathBuf, Vec<u8>)> {
let dir = root.join(&skill.name);
let mut out = Vec::with_capacity(skill.files.len() + 1);
let mut wrote_skill_md = false;
for (rel, bytes) in &skill.files {
if is_skill_md(rel) {
wrote_skill_md = true;
out.push((dir.join(rel), inject(tag, &skill.name, bytes)));
} else {
out.push((dir.join(rel), bytes.clone()));
}
}
if !wrote_skill_md {
out.push((dir.join(SKILL_MD), inject(tag, &skill.name, b"")));
}
out
}
fn skill_md_source(skill: &SkillDir) -> &[u8] {
skill.files.iter().find(|(rel, _)| is_skill_md(rel)).map(|(_, b)| b.as_slice()).unwrap_or(b"")
}
fn inject(tag: &str, name: &str, source: &[u8]) -> Vec<u8> {
let text = String::from_utf8_lossy(source);
let (fm, body) = split_frontmatter(&text);
let has = |key: &str| fm.iter().any(|l| top_key(l) == Some(key));
let mut out = String::from("---\n");
for line in &fm {
if top_key(line) == Some(TAG_KEY) {
continue;
}
out.push_str(line);
out.push('\n');
}
if !has("name") {
let _ = writeln!(out, "name: {}", yaml_scalar(name));
}
if !has("description") {
let _ = writeln!(out, "description: {}", yaml_scalar(name));
}
out.push_str(&tag_line(tag));
out.push_str("\n---\n");
out.push_str(&body);
out.into_bytes()
}
fn tag_line(tag: &str) -> String {
format!("{TAG_KEY}: {}", yaml_scalar(tag))
}
fn is_skill_md(rel: &str) -> bool {
rel == SKILL_MD
}
fn top_key(line: &str) -> Option<&str> {
if line.starts_with([' ', '\t']) {
return None;
}
let key = line.split_once(':')?.0.trim();
(!key.is_empty()).then_some(key)
}
fn slot_ownership(dir: &Path, tag: &str) -> Result<Option<bool>> {
let skill_md = dir.join(SKILL_MD);
match fs::read_to_string(&skill_md) {
Ok(text) => Ok(Some(frontmatter_has_tag(&text, tag))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(Error::Io { context: format!("reading {}", skill_md.display()), source }),
}
}
fn frontmatter_has_tag(text: &str, tag: &str) -> bool {
let (fm, _) = split_frontmatter(text);
let want = yaml_scalar(tag);
fm.iter().any(|line| top_key(line) == Some(TAG_KEY) && line.split_once(':').map(|(_, v)| v.trim()) == Some(want.as_str()))
}
fn split_frontmatter(text: &str) -> (Vec<String>, String) {
let rest = match text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n")) {
Some(rest) => rest,
None => return (Vec::new(), text.to_string()),
};
let mut lines = Vec::new();
let mut pos = 0usize;
while pos < rest.len() {
let nl = rest[pos..].find('\n').map(|i| pos + i);
let line = rest[pos..nl.unwrap_or(rest.len())].trim_end_matches('\r');
let next = nl.map_or(rest.len(), |i| i + 1);
if line == "---" {
return (lines, rest.get(next..).unwrap_or_default().to_string());
}
lines.push(line.to_string());
pos = next;
}
(Vec::new(), text.to_string())
}
#[cfg(test)]
#[path = "../../tests/unit/skillsdir.rs"]
mod skillsdir_tests;