use std::path::Path;
use skillfile_core::error::SkillfileError;
use skillfile_core::models::{EntityType, Entry, Manifest};
use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
use skillfile_sources::strategy::format_parts;
const INSTALL_COMMENT: &str = "# install <platform> <scope>";
fn section_headers(entity_type: &str) -> Vec<&'static str> {
match entity_type {
"agent" => vec![
"# --- Agents ---",
"# github agent [name] <owner/repo> <path-or-dir> [ref]",
],
"skill" => vec![
"# --- Skills ---",
"# github skill [name] <owner/repo> <path-or-dir> [ref]",
],
_ => vec![],
}
}
pub fn format_line(entry: &Entry) -> String {
let mut parts = vec![
entry.source_type().to_string(),
entry.entity_type.to_string(),
];
parts.extend(format_parts(entry));
parts.join(" ")
}
fn sort_key(entry: &Entry) -> (String, String, String) {
let source_type = entry.source_type().to_string();
let (repo, path) = match &entry.source {
skillfile_core::models::SourceFields::Github {
owner_repo,
path_in_repo,
..
} => (owner_repo.clone(), path_in_repo.clone()),
skillfile_core::models::SourceFields::Local { path } => (String::new(), path.clone()),
skillfile_core::models::SourceFields::Url { url } => (String::new(), url.clone()),
};
(source_type, repo, path)
}
fn entry_repo_key(entry: &Entry) -> (String, String) {
let repo = match &entry.source {
skillfile_core::models::SourceFields::Github { owner_repo, .. } => owner_repo.clone(),
_ => String::new(),
};
(entry.source_type().to_string(), repo)
}
fn flush_group<'a>(groups: &mut Vec<Vec<&'a Entry>>, current_group: &mut Vec<&'a Entry>) {
if !current_group.is_empty() {
groups.push(std::mem::take(current_group));
}
}
fn group_by_repo<'a>(entries: &'a [&'a Entry]) -> Vec<Vec<&'a Entry>> {
let mut groups: Vec<Vec<&Entry>> = Vec::new();
let mut current_key: Option<(String, String)> = None;
let mut current_group: Vec<&Entry> = Vec::new();
for entry in entries {
let key = entry_repo_key(entry);
if current_key.as_ref() != Some(&key) {
flush_group(&mut groups, &mut current_group);
current_key = Some(key);
}
current_group.push(entry);
}
flush_group(&mut groups, &mut current_group);
groups
}
fn extract_entry_comments(raw_text: &str) -> std::collections::HashMap<String, Vec<String>> {
let mut attached = std::collections::HashMap::new();
let mut pending: Vec<String> = Vec::new();
for line in raw_text.lines() {
let stripped = line.trim();
if stripped.starts_with('#') {
pending.push(line.trim_end().to_string());
continue;
}
if stripped.is_empty() || stripped.starts_with("install") {
pending.clear();
continue;
}
if !pending.is_empty() {
attached.insert(stripped.to_string(), pending.clone());
}
pending.clear();
}
attached
}
fn push_repo_group(
lines: &mut Vec<String>,
repo_group: &[&Entry],
entry_comments: &std::collections::HashMap<String, Vec<String>>,
) {
lines.push(String::new());
for entry in repo_group {
let formatted = format_line(entry);
lines.extend(
entry_comments
.get(&formatted)
.into_iter()
.flatten()
.cloned(),
);
lines.push(formatted);
}
}
pub fn sorted_manifest_text(manifest: &Manifest, raw_text: &str) -> String {
let entry_comments = if raw_text.is_empty() {
std::collections::HashMap::new()
} else {
extract_entry_comments(raw_text)
};
let mut lines: Vec<String> = Vec::new();
if !manifest.install_targets.is_empty() {
lines.push(INSTALL_COMMENT.to_string());
for target in &manifest.install_targets {
lines.push(format!("install {} {}", target.adapter, target.scope));
}
}
let mut agents: Vec<&Entry> = manifest
.entries
.iter()
.filter(|e| e.entity_type == EntityType::Agent)
.collect();
agents.sort_by_key(|e| sort_key(e));
let mut skills: Vec<&Entry> = manifest
.entries
.iter()
.filter(|e| e.entity_type == EntityType::Skill)
.collect();
skills.sort_by_key(|e| sort_key(e));
for (entity_type, group) in [("agent", agents), ("skill", skills)] {
if group.is_empty() {
continue;
}
lines.push(String::new());
for header in section_headers(entity_type) {
lines.push(header.to_string());
}
for repo_group in group_by_repo(&group) {
push_repo_group(&mut lines, &repo_group, &entry_comments);
}
}
lines.join("\n") + "\n"
}
pub fn cmd_format(repo_root: &Path, dry_run: bool) -> Result<(), SkillfileError> {
let manifest_path = repo_root.join(MANIFEST_NAME);
if !manifest_path.exists() {
return Err(SkillfileError::Manifest(format!(
"{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
repo_root.display()
)));
}
let result = parse_manifest(&manifest_path)?;
let manifest = result.manifest;
let raw_text = std::fs::read_to_string(&manifest_path)?;
let text = sorted_manifest_text(&manifest, &raw_text);
if dry_run {
print!("{text}");
return Ok(());
}
std::fs::write(&manifest_path, &text)?;
let n = manifest.entries.len();
let word = if n == 1 { "entry" } else { "entries" };
println!("Formatted {n} {word}.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use skillfile_core::models::{InstallTarget, Scope, SourceFields};
fn write_manifest(dir: &Path, content: &str) {
std::fs::write(dir.join(MANIFEST_NAME), content).unwrap();
}
fn gh(entity_type: EntityType, owner_repo: &str, path: &str) -> Entry {
let name = path
.rsplit('/')
.next()
.unwrap_or(path)
.trim_end_matches(".md");
Entry {
entity_type,
name: name.to_string(),
source: SourceFields::Github {
owner_repo: owner_repo.into(),
path_in_repo: path.into(),
ref_: "main".into(),
},
}
}
fn itarget(adapter: &str, scope: Scope) -> InstallTarget {
InstallTarget {
adapter: adapter.into(),
scope,
}
}
#[test]
fn install_comment_generated() {
let manifest = Manifest {
entries: vec![gh(EntityType::Skill, "a/repo", "a.md")],
install_targets: vec![itarget("claude-code", Scope::Global)],
};
let text = sorted_manifest_text(
&manifest,
"install claude-code global\ngithub skill a/repo a.md\n",
);
assert!(text.contains("# install <platform> <scope>"));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "# install <platform> <scope>");
assert_eq!(lines[1], "install claude-code global");
}
#[test]
fn agents_section_header_generated() {
let manifest = Manifest {
entries: vec![gh(EntityType::Agent, "owner/repo", "agent.md")],
..Manifest::default()
};
let text = sorted_manifest_text(&manifest, "github agent owner/repo agent.md\n");
assert!(text.contains("# --- Agents ---"));
}
#[test]
fn skills_section_header_generated() {
let manifest = Manifest {
entries: vec![gh(EntityType::Skill, "owner/repo", "skill.md")],
..Manifest::default()
};
let text = sorted_manifest_text(&manifest, "github skill owner/repo skill.md\n");
assert!(text.contains("# --- Skills ---"));
}
#[test]
fn section_format_hint_generated() {
let manifest = Manifest {
entries: vec![
gh(EntityType::Agent, "owner/repo", "agent.md"),
gh(EntityType::Skill, "owner/repo", "skill.md"),
],
..Manifest::default()
};
let text = sorted_manifest_text(
&manifest,
"github agent owner/repo agent.md\ngithub skill owner/repo skill.md\n",
);
assert!(text.contains("# github agent [name] <owner/repo> <path-or-dir> [ref]"));
assert!(text.contains("# github skill [name] <owner/repo> <path-or-dir> [ref]"));
}
#[test]
fn no_install_section_when_no_targets() {
let manifest = Manifest {
entries: vec![gh(EntityType::Skill, "a/repo", "a.md")],
..Manifest::default()
};
let text = sorted_manifest_text(&manifest, "github skill a/repo a.md\n");
assert!(!text.contains("install"));
}
#[test]
fn entries_grouped_by_repo_with_blank_lines() {
let manifest = Manifest {
entries: vec![
gh(EntityType::Skill, "b/repo", "b.md"),
gh(EntityType::Skill, "a/repo", "a.md"),
gh(EntityType::Skill, "a/repo", "z.md"),
],
..Manifest::default()
};
let text = sorted_manifest_text(
&manifest,
"github skill b/repo b.md\ngithub skill a/repo a.md\ngithub skill a/repo z.md\n",
);
let lines: Vec<&str> = text.lines().collect();
let skill_lines: Vec<&&str> = lines
.iter()
.filter(|l| l.starts_with("github skill"))
.collect();
assert_eq!(*skill_lines[0], "github skill a/repo a.md");
assert_eq!(*skill_lines[1], "github skill a/repo z.md");
assert_eq!(*skill_lines[2], "github skill b/repo b.md");
}
#[test]
fn agents_before_skills() {
let manifest = Manifest {
entries: vec![
gh(EntityType::Skill, "owner/repo", "skill.md"),
gh(EntityType::Agent, "owner/repo", "agent.md"),
],
..Manifest::default()
};
let text = sorted_manifest_text(
&manifest,
"github skill owner/repo skill.md\ngithub agent owner/repo agent.md\n",
);
assert!(text.find("# --- Agents ---").unwrap() < text.find("# --- Skills ---").unwrap());
}
#[test]
fn entry_adjacent_comment_preserved() {
let manifest = Manifest {
entries: vec![
gh(EntityType::Skill, "z/repo", "z.md"),
gh(EntityType::Skill, "a/repo", "a.md"),
],
..Manifest::default()
};
let text = sorted_manifest_text(
&manifest,
"github skill z/repo z.md\n# my annotation\ngithub skill a/repo a.md\n",
);
let lines: Vec<&str> = text.lines().collect();
let idx = lines.iter().position(|l| *l == "# my annotation").unwrap();
assert_eq!(lines[idx + 1], "github skill a/repo a.md");
}
#[test]
fn section_comment_dropped() {
let manifest = Manifest {
entries: vec![
gh(EntityType::Skill, "b/repo", "b.md"),
gh(EntityType::Skill, "a/repo", "a.md"),
],
..Manifest::default()
};
let text = sorted_manifest_text(
&manifest,
"# old section header\n\ngithub skill b/repo b.md\ngithub skill a/repo a.md\n",
);
assert!(!text.contains("old section header"));
}
#[test]
fn cmd_format_rewrites_file() {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
"github skill z/repo z.md\ngithub skill a/repo a.md\n",
);
cmd_format(dir.path(), false).unwrap();
let text = std::fs::read_to_string(dir.path().join(MANIFEST_NAME)).unwrap();
let skill_lines: Vec<&str> = text.lines().filter(|l| l.starts_with("github")).collect();
assert_eq!(skill_lines[0], "github skill a/repo a.md");
assert_eq!(skill_lines[1], "github skill z/repo z.md");
}
#[test]
fn cmd_format_dry_run_does_not_write() {
let dir = tempfile::tempdir().unwrap();
let original = "github skill z/repo z.md\ngithub skill a/repo a.md\n";
write_manifest(dir.path(), original);
cmd_format(dir.path(), true).unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join(MANIFEST_NAME)).unwrap(),
original
);
}
#[test]
fn cmd_format_no_manifest() {
let dir = tempfile::tempdir().unwrap();
let result = cmd_format(dir.path(), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
}