use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use crate::engine::builtins;
use crate::engine::flow::split_frontmatter;
use crate::engine::LoadError;
const SKILL_FILE_NAME: &str = "SKILL.md";
const LOOPFLOW_MARKER: &str = "loopflow: true";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillSyncOptions {
pub include_global: bool,
pub prune: bool,
pub global_home: Option<PathBuf>,
}
impl Default for SkillSyncOptions {
fn default() -> Self {
Self {
include_global: false,
prune: true,
global_home: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SkillSyncReport {
pub written: Vec<PathBuf>,
pub pruned: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Vendor {
Claude,
Codex,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StepSource {
name: String,
content: String,
}
pub fn sync_skills(
repo_root: &Path,
options: &SkillSyncOptions,
) -> Result<SkillSyncReport, LoadError> {
let repo_steps = collect_repo_steps(repo_root)?;
let home = options.global_home.clone().or_else(dirs::home_dir);
let global_steps = home
.as_deref()
.map(collect_global_steps)
.transpose()?
.unwrap_or_default();
let mut global_resolved = collect_builtin_steps();
global_resolved.extend(global_steps);
let mut repo_resolved = global_resolved.clone();
repo_resolved.extend(repo_steps);
ensure_repo_skill_excludes(repo_root)?;
let mut report = SkillSyncReport::default();
write_targets(
&repo_resolved,
&repo_root.join(".claude/skills"),
Vendor::Claude,
options.prune,
&mut report,
)?;
write_targets(
&repo_resolved,
&repo_root.join(".agents/skills"),
Vendor::Codex,
options.prune,
&mut report,
)?;
if options.include_global {
let home =
home.ok_or_else(|| LoadError::InvalidStep("home directory not found".to_string()))?;
write_targets(
&global_resolved,
&home.join(".claude/skills"),
Vendor::Claude,
options.prune,
&mut report,
)?;
write_targets(
&global_resolved,
&home.join(".agents/skills"),
Vendor::Codex,
options.prune,
&mut report,
)?;
}
report.written.sort();
report.pruned.sort();
Ok(report)
}
fn ensure_repo_skill_excludes(repo_root: &Path) -> Result<(), LoadError> {
let exclude_path = repo_root.join(".git/info/exclude");
if !exclude_path.exists() {
return Ok(());
}
let mut content = fs::read_to_string(&exclude_path)?;
let mut changed = false;
for line in [".claude/skills/", ".agents/skills/"] {
if !content.lines().any(|existing| existing.trim() == line) {
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(line);
content.push('\n');
changed = true;
}
}
if changed {
fs::write(exclude_path, content)?;
}
Ok(())
}
fn collect_builtin_steps() -> BTreeMap<String, StepSource> {
builtins::builtin_step_names()
.into_iter()
.filter_map(|name| {
builtins::get_builtin_step(name).map(|content| {
(
name.to_string(),
StepSource {
name: name.to_string(),
content: content.to_string(),
},
)
})
})
.collect()
}
fn collect_repo_steps(repo_root: &Path) -> Result<BTreeMap<String, StepSource>, LoadError> {
let mut steps = BTreeMap::new();
collect_step_dir(&repo_root.join(".lf/steps"), &mut steps)?;
collect_step_dir(&repo_root.join(".claude/commands"), &mut steps)?;
Ok(steps)
}
fn collect_global_steps(home: &Path) -> Result<BTreeMap<String, StepSource>, LoadError> {
let mut steps = BTreeMap::new();
collect_step_dir(&home.join(".lf/steps"), &mut steps)?;
collect_step_dir(&home.join(".claude/commands"), &mut steps)?;
Ok(steps)
}
fn collect_step_dir(dir: &Path, steps: &mut BTreeMap<String, StepSource>) -> Result<(), LoadError> {
if !dir.is_dir() {
return Ok(());
}
for path in markdown_files(dir)? {
let Some(name) = step_name_from_path(dir, &path) else {
continue;
};
let content = fs::read_to_string(&path)?;
steps.insert(name.clone(), StepSource { name, content });
}
Ok(())
}
fn markdown_files(dir: &Path) -> Result<Vec<PathBuf>, LoadError> {
let mut files = Vec::new();
collect_markdown_files(dir, &mut files)?;
files.sort();
Ok(files)
}
fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), LoadError> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_markdown_files(&path, files)?;
} else if path.extension().is_some_and(|ext| ext == "md") {
files.push(path);
}
}
Ok(())
}
fn step_name_from_path(root: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let mut without_extension = relative.to_path_buf();
without_extension.set_extension("");
Some(
without_extension
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)
}
fn write_targets(
steps: &BTreeMap<String, StepSource>,
target_root: &Path,
vendor: Vendor,
prune: bool,
report: &mut SkillSyncReport,
) -> Result<(), LoadError> {
fs::create_dir_all(target_root)?;
let desired: BTreeSet<String> = steps.keys().cloned().collect();
for step in steps.values() {
let path = skill_path(target_root, &step.name);
let content = render_skill(step, vendor);
if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) {
continue;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, content)?;
report.written.push(path);
}
if prune {
for path in generated_skill_files(target_root)? {
let Some(name) = skill_name_from_path(target_root, &path) else {
continue;
};
if desired.contains(&name) {
continue;
}
fs::remove_file(&path)?;
report.pruned.push(path.clone());
prune_empty_skill_dir(&path, target_root)?;
}
}
Ok(())
}
fn render_skill(step: &StepSource, vendor: Vendor) -> String {
let (original_frontmatter, body) =
split_frontmatter(&step.content).unwrap_or_else(|| (String::new(), step.content.clone()));
let description = skill_description(&original_frontmatter, &body, &step.name);
let mut frontmatter = Vec::new();
frontmatter.push(format!("name: {}", yaml_string(&step.name)));
frontmatter.push(format!("description: {}", yaml_string(&description)));
frontmatter.push(LOOPFLOW_MARKER.to_string());
frontmatter.push(format!("loopflow-step: {}", yaml_string(&step.name)));
if vendor == Vendor::Claude {
frontmatter.push("disable-model-invocation: true".to_string());
}
format!("---\n{}\n---\n{}", frontmatter.join("\n"), body)
}
fn skill_description(frontmatter: &str, body: &str, name: &str) -> String {
if let Ok(value) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(frontmatter) {
if let Some(description) = value
.as_mapping()
.and_then(|map| map.get(serde_yaml_ng::Value::String("description".to_string())))
.and_then(serde_yaml_ng::Value::as_str)
.map(first_line)
.filter(|line| !line.is_empty())
{
return description;
}
}
first_prose_line(body).unwrap_or_else(|| format!("Run the loopflow {name} step."))
}
fn first_line(value: &str) -> String {
value.lines().next().unwrap_or("").trim().to_string()
}
fn first_prose_line(body: &str) -> Option<String> {
for raw in body.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("```") {
continue;
}
return Some(line.to_string());
}
None
}
fn yaml_string(value: &str) -> String {
serde_yaml_ng::to_string(value)
.unwrap_or_else(|_| format!("\"{}\"", value.replace('"', "\\\"")))
.trim()
.trim_start_matches("---\n")
.trim_end_matches("\n...")
.to_string()
}
fn skill_path(root: &Path, name: &str) -> PathBuf {
name.split('/')
.fold(root.to_path_buf(), |path, segment| path.join(segment))
.join(SKILL_FILE_NAME)
}
fn generated_skill_files(root: &Path) -> Result<Vec<PathBuf>, LoadError> {
if !root.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
collect_skill_files(root, &mut files)?;
files.retain(|path| is_loopflow_generated(path));
Ok(files)
}
fn collect_skill_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), LoadError> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_skill_files(&path, files)?;
} else if path.file_name().is_some_and(|name| name == SKILL_FILE_NAME) {
files.push(path);
}
}
Ok(())
}
fn is_loopflow_generated(path: &Path) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Some((frontmatter, _body)) = split_frontmatter(&content) else {
return false;
};
frontmatter
.lines()
.any(|line| line.trim() == LOOPFLOW_MARKER)
}
fn skill_name_from_path(root: &Path, skill_file: &Path) -> Option<String> {
let dir = skill_file.parent()?;
Some(
dir.strip_prefix(root)
.ok()?
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)
}
fn prune_empty_skill_dir(skill_file: &Path, target_root: &Path) -> Result<(), LoadError> {
let mut current = match skill_file.parent() {
Some(parent) => parent.to_path_buf(),
None => return Ok(()),
};
while current != target_root {
match fs::remove_dir(¤t) {
Ok(()) => {
let Some(parent) = current.parent() else {
break;
};
current = parent.to_path_buf();
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
Err(err) => return Err(err.into()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn sync_skills_writes_repo_targets_with_marker() {
let repo = TempDir::new().unwrap();
let steps = repo.path().join(".lf/steps");
fs::create_dir_all(&steps).unwrap();
fs::write(
steps.join("local.md"),
"---\nagent: codex:o3\n---\nLocal step summary.\n\nDo it.\n",
)
.unwrap();
let report = sync_skills(repo.path(), &SkillSyncOptions::default()).unwrap();
assert!(report
.written
.iter()
.any(|path| path.ends_with(".claude/skills/local/SKILL.md")));
assert!(report
.written
.iter()
.any(|path| path.ends_with(".agents/skills/local/SKILL.md")));
let claude = fs::read_to_string(repo.path().join(".claude/skills/local/SKILL.md")).unwrap();
assert!(claude.contains("description: Local step summary."));
assert!(claude.contains("loopflow: true"));
assert!(claude.contains("loopflow-step: local"));
assert!(claude.contains("disable-model-invocation: true"));
assert!(!claude.contains("agent: codex:o3"));
assert!(claude.contains("Do it."));
let codex = fs::read_to_string(repo.path().join(".agents/skills/local/SKILL.md")).unwrap();
assert!(!codex.contains("disable-model-invocation"));
}
#[test]
fn sync_skills_prunes_only_generated_skills() {
let repo = TempDir::new().unwrap();
let stale_dir = repo.path().join(".agents/skills/stale");
fs::create_dir_all(&stale_dir).unwrap();
fs::write(
stale_dir.join(SKILL_FILE_NAME),
"---\nname: stale\nloopflow: true\n---\nold\n",
)
.unwrap();
let user_dir = repo.path().join(".agents/skills/user");
fs::create_dir_all(&user_dir).unwrap();
fs::write(
user_dir.join(SKILL_FILE_NAME),
"---\nname: user\n---\nkeep\n",
)
.unwrap();
let report = sync_skills(repo.path(), &SkillSyncOptions::default()).unwrap();
assert!(report
.pruned
.iter()
.any(|path| path.ends_with(".agents/skills/stale/SKILL.md")));
assert!(!stale_dir.join(SKILL_FILE_NAME).exists());
assert!(user_dir.join(SKILL_FILE_NAME).exists());
}
#[test]
fn sync_skills_excludes_repo_targets_from_git() {
let repo = TempDir::new().unwrap();
let exclude = repo.path().join(".git/info/exclude");
fs::create_dir_all(exclude.parent().unwrap()).unwrap();
fs::write(
&exclude,
"# local excludes
",
)
.unwrap();
sync_skills(repo.path(), &SkillSyncOptions::default()).unwrap();
let content = fs::read_to_string(exclude).unwrap();
assert!(content.contains(".claude/skills/"));
assert!(content.contains(".agents/skills/"));
}
#[test]
fn sync_skills_writes_global_without_repo_local_steps() {
let repo = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let repo_steps = repo.path().join(".lf/steps");
fs::create_dir_all(&repo_steps).unwrap();
fs::write(repo_steps.join("repo-only.md"), "Repo only.\n").unwrap();
let global_steps = home.path().join(".lf/steps");
fs::create_dir_all(&global_steps).unwrap();
fs::write(global_steps.join("global-only.md"), "Global only.\n").unwrap();
sync_skills(
repo.path(),
&SkillSyncOptions {
include_global: true,
prune: true,
global_home: Some(home.path().to_path_buf()),
},
)
.unwrap();
assert!(home
.path()
.join(".agents/skills/global-only/SKILL.md")
.exists());
assert!(!home
.path()
.join(".agents/skills/repo-only/SKILL.md")
.exists());
assert!(repo
.path()
.join(".agents/skills/repo-only/SKILL.md")
.exists());
assert!(repo
.path()
.join(".agents/skills/global-only/SKILL.md")
.exists());
}
}