use crate::Heading;
use crate::config::ensure_dir;
use crate::error::{Result, SkillcError};
use crate::frontmatter::{self, Frontmatter};
use crate::markdown;
use crate::search;
use crate::verbose;
use chrono::Utc;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;
#[derive(Debug, Serialize)]
pub struct Manifest {
pub skill: String,
pub version: u32,
pub built_at: String,
pub source_hash: String,
}
pub fn compile(source_dir: &Path, runtime_dir: &Path) -> Result<()> {
let start = Instant::now();
verbose!("build: source_dir={}", source_dir.display());
verbose!("build: runtime_dir={}", runtime_dir.display());
crate::util::validate_skill_path(source_dir)?;
let skill_md_path = source_dir.join("SKILL.md");
check_symlink_safety(source_dir)?;
let skill_md_content = fs::read_to_string(&skill_md_path)?;
let frontmatter = frontmatter::parse(&skill_md_content)?;
verbose!("build: skill name=\"{}\"", frontmatter.name);
let headings = extract_headings(source_dir)?;
verbose!("build: extracted {} headings", headings.len());
let md_files = list_md_files(source_dir)?;
verbose!("build: found {} markdown files", md_files.len());
let descriptions = extract_reference_descriptions(source_dir, &md_files);
verbose!("build: found {} reference descriptions", descriptions.len());
let source_hash = compute_source_hash(source_dir)?;
verbose!("build: source_hash={}", &source_hash[..16]);
let stub = generate_stub(&frontmatter, &headings, &descriptions);
let manifest = Manifest {
skill: frontmatter.name.clone(),
version: 1,
built_at: Utc::now().to_rfc3339(),
source_hash,
};
ensure_dir(runtime_dir)?;
let stub_path = runtime_dir.join("SKILL.md");
fs::write(&stub_path, &stub)?;
verbose!("build: wrote stub ({} bytes)", stub.len());
let manifest_dir = runtime_dir.join(".skillc-meta");
ensure_dir(&manifest_dir)?;
let manifest_path = manifest_dir.join("manifest.json");
let manifest_json = serde_json::to_string_pretty(&manifest)?;
fs::write(&manifest_path, &manifest_json)?;
search::build_index(source_dir, runtime_dir, &manifest.source_hash)?;
verbose!("build: completed in {:?}", start.elapsed());
Ok(())
}
fn extract_headings(source_dir: &Path) -> Result<Vec<Heading>> {
let mut md_files: Vec<PathBuf> = WalkDir::new(source_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.map(|e| e.path().to_path_buf())
.collect();
md_files.sort_by(|a, b| {
let a_is_skill_md = a.file_name().is_some_and(|n| n == "SKILL.md");
let b_is_skill_md = b.file_name().is_some_and(|n| n == "SKILL.md");
match (a_is_skill_md, b_is_skill_md) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.cmp(b),
}
});
let mut headings = Vec::new();
for path in md_files {
let content = fs::read_to_string(&path)?;
let relative_path = path
.strip_prefix(source_dir)
.map_err(|_| SkillcError::Internal("path does not start with source_dir".into()))?
.to_path_buf();
for extracted in markdown::extract_headings(&content) {
headings.push(Heading {
level: extracted.level,
text: extracted.text,
file: relative_path.clone(),
line_number: extracted.line,
});
}
}
Ok(headings)
}
fn list_md_files(source_dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
{
let relative_path = entry
.path()
.strip_prefix(source_dir)
.map_err(|_| {
SkillcError::Internal(format!(
"path {} does not start with {}",
entry.path().display(),
source_dir.display()
))
})?
.to_path_buf();
files.push(relative_path);
}
files.sort();
Ok(files)
}
fn compute_source_hash(source_dir: &Path) -> Result<String> {
let mut file_hashes: Vec<(String, String)> = Vec::new();
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_entry(|e| {
!e.file_type().is_dir() || !e.file_name().to_string_lossy().starts_with('.')
})
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let relative_path = entry
.path()
.strip_prefix(source_dir)
.map_err(|_| SkillcError::Internal("path does not start with source_dir".into()))?
.to_string_lossy()
.to_string();
let content = fs::read(entry.path())?;
let mut hasher = Sha256::new();
hasher.update(&content);
let file_hash = format!("{:x}", hasher.finalize());
file_hashes.push((relative_path, file_hash));
}
file_hashes.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (path, hash) in &file_hashes {
hasher.update(path.as_bytes());
hasher.update(hash.as_bytes());
}
Ok(format!("{:x}", hasher.finalize()))
}
fn check_symlink_safety(source_dir: &Path) -> Result<()> {
let canonical_root = source_dir
.canonicalize()
.map_err(|e| SkillcError::Internal(format!("Failed to canonicalize source dir: {}", e)))?;
for entry in WalkDir::new(source_dir)
.follow_links(false) .into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
match path.canonicalize() {
Ok(resolved) => {
if !resolved.starts_with(&canonical_root) {
let relative = path
.strip_prefix(source_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
return Err(SkillcError::PathEscapesRoot(relative));
}
}
Err(_) => {
verbose!("build: skipping broken symlink: {}", path.display());
}
}
}
}
Ok(())
}
#[allow(dead_code)]
const MAX_STUB_LINES: usize = 100;
const MAX_SKILL_SECTION_ENTRIES: usize = 15;
const MAX_REFERENCE_ENTRIES: usize = 15;
const MAX_REFERENCE_DESCRIPTION_LEN: usize = 120;
#[derive(Debug)]
struct SectionEntry {
text: String,
indent: usize,
}
fn extract_reference_descriptions(
source_dir: &Path,
files: &[PathBuf],
) -> HashMap<PathBuf, String> {
let mut descriptions = HashMap::new();
for file in files {
if file.file_name().is_some_and(|n| n == "SKILL.md") {
continue;
}
let full_path = source_dir.join(file);
if let Ok(content) = fs::read_to_string(&full_path)
&& let Some(desc) = extract_description_from_frontmatter(&content)
{
descriptions.insert(file.clone(), desc);
}
}
descriptions
}
fn extract_description_from_frontmatter(content: &str) -> Option<String> {
if !content.starts_with("---") {
return None;
}
let rest = &content[3..];
let close_pos = rest.find("\n---")?;
let yaml_block = &rest[..close_pos].trim();
for line in yaml_block.lines() {
let line = line.trim();
if line.starts_with("description:") {
let value = line.strip_prefix("description:")?.trim();
let desc = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
if !desc.is_empty() {
return Some(desc.to_string());
}
}
}
None
}
fn truncate_description(desc: &str, max_len: usize) -> String {
if desc.chars().count() <= max_len {
desc.to_string()
} else {
let truncated: String = desc.chars().take(max_len - 1).collect();
format!("{}…", truncated.trim_end())
}
}
fn build_section_entries(
headings: &[Heading],
descriptions: &HashMap<PathBuf, String>,
) -> Vec<SectionEntry> {
let mut skill_entries = Vec::new();
let mut seen_files: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut reference_entries = Vec::new();
for heading in headings {
let is_skill_md = heading.file.file_name().is_some_and(|n| n == "SKILL.md");
if is_skill_md {
match heading.level {
1 => skill_entries.push(SectionEntry {
text: heading.text.clone(),
indent: 0,
}),
2 => skill_entries.push(SectionEntry {
text: heading.text.clone(),
indent: 1,
}),
_ => {} }
}
}
for heading in headings {
let is_skill_md = heading.file.file_name().is_some_and(|n| n == "SKILL.md");
if !is_skill_md {
if seen_files.contains(&heading.file) {
continue;
}
if heading.level == 1 {
seen_files.insert(heading.file.clone());
let text = if let Some(desc) = descriptions.get(&heading.file) {
let truncated = truncate_description(desc, MAX_REFERENCE_DESCRIPTION_LEN);
format!("{} — {}", heading.text, truncated)
} else {
heading.text.clone()
};
reference_entries.push(SectionEntry { text, indent: 1 });
}
}
}
let files_with_headings: std::collections::HashSet<_> = headings
.iter()
.filter(|h| h.file.file_name().is_none_or(|n| n != "SKILL.md"))
.map(|h| &h.file)
.collect();
for file in files_with_headings {
if !seen_files.contains(file) {
let text = if let Some(desc) = descriptions.get(file) {
let truncated = truncate_description(desc, MAX_REFERENCE_DESCRIPTION_LEN);
format!("{} — {}", file.display(), truncated)
} else {
file.display().to_string()
};
reference_entries.push(SectionEntry { text, indent: 1 });
}
}
let skill_omitted = skill_entries
.len()
.saturating_sub(MAX_SKILL_SECTION_ENTRIES);
let refs_omitted = reference_entries
.len()
.saturating_sub(MAX_REFERENCE_ENTRIES);
skill_entries.truncate(MAX_SKILL_SECTION_ENTRIES);
reference_entries.truncate(MAX_REFERENCE_ENTRIES);
let mut entries = skill_entries;
if skill_omitted > 0 {
entries.push(SectionEntry {
text: format!("... ({} more)", skill_omitted),
indent: 1,
});
}
if !reference_entries.is_empty() {
entries.push(SectionEntry {
text: "References (query by title only)".to_string(),
indent: 0,
});
entries.extend(reference_entries);
if refs_omitted > 0 {
entries.push(SectionEntry {
text: format!("... ({} more)", refs_omitted),
indent: 1,
});
}
}
entries
}
fn generate_stub(
frontmatter: &Frontmatter,
headings: &[Heading],
descriptions: &HashMap<PathBuf, String>,
) -> String {
let mut stub = String::new();
stub.push_str("---\n");
stub.push_str(&format!("name: {}\n", frontmatter.name));
stub.push_str(&format!("description: \"{}\"\n", frontmatter.description));
stub.push_str("---\n\n");
stub.push_str(&format!("# {} (compiled)\n\n", frontmatter.name));
stub.push_str("DO NOT read skill source files directly.\n");
stub.push_str("Use the skillc gateway to access content.\n\n");
stub.push_str("## Usage\n\n");
stub.push_str("**Prefer MCP if available:** Use skillc MCP tools (`skc_outline`, `skc_show`, `skc_search`, etc.) for better performance and structured output.\n\n");
stub.push_str("**CLI fallback:**\n");
stub.push_str(&format!(
"- `skc outline {}` — list sections\n",
frontmatter.name
));
stub.push_str(&format!(
"- `skc show {} --section \"<Heading>\"` — view section content\n",
frontmatter.name
));
stub.push_str(&format!(
"- `skc open {} <relative-path>` — open file\n",
frontmatter.name
));
stub.push_str(&format!(
"- `skc sources {}` — list source files\n",
frontmatter.name
));
stub.push_str(&format!(
"- `skc search {} <query>` — search content\n\n",
frontmatter.name
));
let entries = build_section_entries(headings, descriptions);
stub.push_str("## Top Sections\n\n");
for entry in &entries {
let indent = " ".repeat(entry.indent);
stub.push_str(&format!("{}- {}\n", indent, entry.text));
}
stub
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frontmatter_parsing() {
let content = r#"---
name: test-skill
description: A test skill
---
# Content here
"#;
let fm = frontmatter::parse(content).expect("failed to parse frontmatter");
assert_eq!(fm.name, "test-skill");
assert_eq!(fm.description, "A test skill");
}
#[test]
fn test_build_section_entries_skill_md_only() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 2,
text: "Section One".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 5,
},
Heading {
level: 2,
text: "Section Two".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 10,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[0].indent, 0);
assert_eq!(entries[1].text, "Section One");
assert_eq!(entries[1].indent, 1);
assert_eq!(entries[2].text, "Section Two");
assert_eq!(entries[2].indent, 1);
}
#[test]
fn test_build_section_entries_skips_h3_plus() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 2,
text: "Section".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 5,
},
Heading {
level: 3,
text: "Subsection".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 10,
},
Heading {
level: 4,
text: "Deep".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 15,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[1].text, "Section");
}
#[test]
fn test_build_section_entries_with_references() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 1,
text: "Reference Doc".to_string(),
file: PathBuf::from("docs/reference.md"),
line_number: 1,
},
Heading {
level: 1,
text: "Another Doc".to_string(),
file: PathBuf::from("docs/another.md"),
line_number: 1,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[0].indent, 0);
assert_eq!(entries[1].text, "References (query by title only)");
assert_eq!(entries[1].indent, 0);
assert_eq!(entries[2].text, "Reference Doc");
assert_eq!(entries[2].indent, 1);
assert_eq!(entries[3].text, "Another Doc");
assert_eq!(entries[3].indent, 1);
}
#[test]
fn test_build_section_entries_no_references_when_empty() {
let headings = vec![Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
}];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "My Skill");
}
#[test]
fn test_build_section_entries_uses_first_h1_from_other_files() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 1,
text: "First H1".to_string(),
file: PathBuf::from("docs/multi.md"),
line_number: 1,
},
Heading {
level: 1,
text: "Second H1".to_string(),
file: PathBuf::from("docs/multi.md"),
line_number: 10,
},
Heading {
level: 2,
text: "Some H2".to_string(),
file: PathBuf::from("docs/multi.md"),
line_number: 20,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[1].text, "References (query by title only)");
assert_eq!(entries[2].text, "First H1");
}
#[test]
fn test_build_section_entries_truncation_with_count() {
let mut headings: Vec<Heading> = (0..20)
.map(|i| Heading {
level: if i == 0 { 1 } else { 2 },
text: format!("Section {}", i),
file: PathBuf::from("SKILL.md"),
line_number: i + 1,
})
.collect();
for i in 0..20 {
headings.push(Heading {
level: 1,
text: format!("Ref {}", i),
file: PathBuf::from(format!("refs/ref{}.md", i)),
line_number: 1,
});
}
let entries = build_section_entries(&headings, &HashMap::new());
let skill_ellipsis = entries
.iter()
.find(|e| e.text.starts_with("... (") && e.text.contains("5 more"));
assert!(
skill_ellipsis.is_some(),
"Should have ellipsis showing 5 more for SKILL.md sections"
);
assert!(
entries.iter().any(|e| e.text.starts_with("References")),
"Should have References section"
);
let refs_ellipsis = entries
.iter()
.find(|e| e.text.starts_with("... (") && e.text.contains("5 more"));
assert!(
refs_ellipsis.is_some(),
"Should have ellipsis showing 5 more for References"
);
}
#[test]
fn test_build_section_entries_multiple_h1_in_skill_md() {
let headings = vec![
Heading {
level: 1,
text: "Main Title".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 2,
text: "Subsection".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 5,
},
Heading {
level: 1,
text: "Second Title".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 10,
},
Heading {
level: 2,
text: "Another Sub".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 15,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].text, "Main Title");
assert_eq!(entries[0].indent, 0);
assert_eq!(entries[1].text, "Subsection");
assert_eq!(entries[1].indent, 1);
assert_eq!(entries[2].text, "Second Title");
assert_eq!(entries[2].indent, 0);
assert_eq!(entries[3].text, "Another Sub");
assert_eq!(entries[3].indent, 1);
}
#[test]
fn test_build_section_entries_fallback_to_filename() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 2,
text: "Some H2".to_string(),
file: PathBuf::from("docs/no-h1.md"),
line_number: 1,
},
];
let entries = build_section_entries(&headings, &HashMap::new());
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[1].text, "References (query by title only)");
assert_eq!(entries[2].text, "docs/no-h1.md");
assert_eq!(entries[2].indent, 1);
}
#[test]
fn test_extract_description_from_frontmatter() {
let content = r#"---
description: "Test description"
---
# Title
"#;
let desc = extract_description_from_frontmatter(content);
assert_eq!(desc, Some("Test description".to_string()));
let content = "# Title\n\nContent";
let desc = extract_description_from_frontmatter(content);
assert_eq!(desc, None);
let content = r#"---
other: value
---
# Title
"#;
let desc = extract_description_from_frontmatter(content);
assert_eq!(desc, None);
let content = r#"---
description: 'Single quoted'
---
"#;
let desc = extract_description_from_frontmatter(content);
assert_eq!(desc, Some("Single quoted".to_string()));
let content = r#"---
description: Unquoted value
---
"#;
let desc = extract_description_from_frontmatter(content);
assert_eq!(desc, Some("Unquoted value".to_string()));
}
#[test]
fn test_truncate_description() {
let desc = "Short description";
assert_eq!(truncate_description(desc, 120), "Short description");
let desc = "x".repeat(120);
assert_eq!(truncate_description(&desc, 120), desc);
let desc = "x".repeat(130);
let result = truncate_description(&desc, 120);
assert!(result.ends_with('…'));
assert!(result.chars().count() <= 120);
}
#[test]
fn test_build_section_entries_with_descriptions() {
let headings = vec![
Heading {
level: 1,
text: "My Skill".to_string(),
file: PathBuf::from("SKILL.md"),
line_number: 1,
},
Heading {
level: 1,
text: "Clap Patterns".to_string(),
file: PathBuf::from("refs/clap.md"),
line_number: 1,
},
Heading {
level: 1,
text: "Error Handling".to_string(),
file: PathBuf::from("refs/errors.md"),
line_number: 1,
},
];
let mut descriptions = HashMap::new();
descriptions.insert(
PathBuf::from("refs/clap.md"),
"Advanced argument parsing".to_string(),
);
let entries = build_section_entries(&headings, &descriptions);
assert_eq!(entries.len(), 4); assert_eq!(entries[0].text, "My Skill");
assert_eq!(entries[1].text, "References (query by title only)");
assert_eq!(entries[2].text, "Clap Patterns — Advanced argument parsing");
assert_eq!(entries[3].text, "Error Handling"); }
}