1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, anyhow, bail};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug)]
12pub struct Skill {
13 pub path: PathBuf,
14 pub name: String,
15 pub description: String,
16}
17
18#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20#[allow(dead_code)]
21struct SkillFrontmatter {
22 name: String,
23 description: String,
24 #[serde(default)]
25 license: Option<String>,
26 #[serde(default)]
27 compatibility: Option<String>,
28 #[serde(default)]
29 metadata: Option<serde_yaml::Mapping>,
30 #[serde(default, rename = "allowed-tools")]
31 allowed_tools: Option<String>,
32}
33
34pub fn read_skill(path: &Path) -> Result<Skill> {
35 if !path.is_dir() {
36 bail!("skill path is not a directory: {}", path.display());
37 }
38
39 let skill_file = path.join("SKILL.md");
40 let contents = fs::read_to_string(&skill_file)
41 .with_context(|| format!("failed to read {}", skill_file.display()))?;
42 let frontmatter = parse_frontmatter(&contents)
43 .with_context(|| format!("failed to parse {}", skill_file.display()))?;
44
45 Ok(Skill {
46 path: path.to_path_buf(),
47 name: frontmatter.name,
48 description: frontmatter.description,
49 })
50}
51
52fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
53 let mut lines = contents.lines();
54 if lines.next() != Some("---") {
55 bail!("SKILL.md must start with YAML frontmatter delimited by ---");
56 }
57
58 let mut yaml = String::new();
59 for line in lines {
60 if line == "---" {
61 let frontmatter = serde_yaml::from_str(&yaml)?;
62 return Ok(frontmatter);
63 }
64 yaml.push_str(line);
65 yaml.push('\n');
66 }
67
68 bail!("SKILL.md frontmatter is missing closing ---");
69}
70
71pub fn validate_skill(skill: &Skill) -> Result<()> {
72 validate_skill_name(&skill.name)?;
73
74 let dirname = skill
75 .path
76 .file_name()
77 .and_then(|name| name.to_str())
78 .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
79 if dirname != skill.name {
80 bail!(
81 "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
82 skill.name,
83 dirname
84 );
85 }
86
87 if skill.description.trim().is_empty() {
88 bail!("description must not be empty");
89 }
90
91 if skill.description.chars().count() > 1024 {
92 bail!("description must be at most 1024 characters");
93 }
94
95 Ok(())
96}
97
98pub fn validate_skill_name(name: &str) -> Result<()> {
99 let len = name.chars().count();
100 if len == 0 || len > 64 {
101 bail!("skill name must be 1-64 characters");
102 }
103
104 if name.starts_with('-') || name.ends_with('-') {
105 bail!("skill name must not start or end with a hyphen");
106 }
107
108 if name.contains("--") {
109 bail!("skill name must not contain consecutive hyphens");
110 }
111
112 if !name
113 .chars()
114 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
115 {
116 bail!("skill name may only contain lowercase letters, numbers, and hyphens");
117 }
118
119 Ok(())
120}
121
122pub fn checksum_dir(path: &Path) -> Result<String> {
123 let mut hasher = Sha256::new();
124 for file in collect_files(path)? {
125 let relative_path = file.strip_prefix(path).with_context(|| {
126 format!(
127 "failed to make {} relative to {}",
128 file.display(),
129 path.display()
130 )
131 })?;
132 hasher.update(relative_path.to_string_lossy().as_bytes());
133 hasher.update([0]);
134 hasher
135 .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
136 hasher.update([0]);
137 }
138 Ok(format!("sha256:{:x}", hasher.finalize()))
139}
140
141pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
142 let mut files = Vec::new();
143 collect_files_inner(path, &mut files)?;
144 files.sort();
145 Ok(files)
146}
147
148fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
149 for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
150 let entry = entry?;
151 let path = entry.path();
152 let file_type = entry.file_type()?;
153
154 if file_type.is_dir() {
155 collect_files_inner(&path, files)?;
156 } else if file_type.is_file() {
157 files.push(path);
158 }
159 }
160
161 Ok(())
162}
163
164#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct Manifest {
166 pub install: InstallConfig,
167 #[serde(default)]
168 pub skills: BTreeMap<String, String>,
169 #[serde(default)]
170 pub registries: BTreeMap<String, RegistryConfig>,
171}
172
173impl Manifest {
174 pub fn new(target: PathBuf) -> Self {
175 Self {
176 install: InstallConfig { target },
177 skills: BTreeMap::new(),
178 registries: BTreeMap::new(),
179 }
180 }
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct InstallConfig {
185 pub target: PathBuf,
186}
187
188#[derive(Debug, Default, Deserialize, Serialize)]
189pub struct Lockfile {
190 #[serde(default)]
191 pub skill: Vec<LockedSkill>,
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize)]
195pub struct LockedSkill {
196 pub name: String,
197 pub source: String,
198 pub resolved: String,
199 pub checksum: String,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct RegistryConfig {
204 pub kind: RegistryKind,
205 pub url: String,
206 pub default_ref: String,
207}
208
209#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum RegistryKind {
212 GitHost,
213 Http,
214}
215
216#[derive(Debug, Default, Clone, Deserialize, Serialize)]
217pub struct RegistryIndex {
218 #[serde(default)]
219 pub skill: Vec<IndexedSkill>,
220 #[serde(default)]
221 pub source: Vec<IndexSource>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct IndexedSkill {
226 pub name: String,
227 pub description: String,
228 pub source: String,
229 #[serde(default)]
230 pub tags: Vec<String>,
231}
232
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct IndexSource {
235 pub source: String,
236 #[serde(default)]
237 pub tags: Vec<String>,
238}
239
240impl RegistryIndex {
241 pub fn validate(&self) -> Result<()> {
242 for skill in &self.skill {
243 skill.validate()?;
244 }
245 for source in &self.source {
246 source.validate()?;
247 }
248 Ok(())
249 }
250
251 pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
252 let terms: Vec<String> = query
253 .split_whitespace()
254 .map(|term| term.to_ascii_lowercase())
255 .collect();
256 if terms.is_empty() {
257 return Vec::new();
258 }
259
260 self.skill
261 .iter()
262 .filter(|skill| {
263 let haystack = skill.search_text();
264 terms.iter().all(|term| haystack.contains(term))
265 })
266 .collect()
267 }
268}
269
270impl IndexSource {
271 pub fn validate(&self) -> Result<()> {
272 if self.source.trim().is_empty() {
273 bail!("indexed source must not be empty");
274 }
275 Ok(())
276 }
277}
278
279impl IndexedSkill {
280 pub fn validate(&self) -> Result<()> {
281 validate_skill_name(&self.name)?;
282 if self.description.trim().is_empty() {
283 bail!("indexed skill description must not be empty: {}", self.name);
284 }
285 if self.source.trim().is_empty() {
286 bail!("indexed skill source must not be empty: {}", self.name);
287 }
288 Ok(())
289 }
290
291 fn search_text(&self) -> String {
292 format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn validates_skill_names() {
302 assert!(validate_skill_name("rust-code-review").is_ok());
303 assert!(validate_skill_name("Rust-Code-Review").is_err());
304 assert!(validate_skill_name("-rust").is_err());
305 assert!(validate_skill_name("rust-").is_err());
306 assert!(validate_skill_name("rust--review").is_err());
307 }
308
309 #[test]
310 fn parses_frontmatter() {
311 let frontmatter =
312 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
313 .expect("frontmatter should parse");
314
315 assert_eq!(frontmatter.name, "demo-skill");
316 assert_eq!(frontmatter.description, "Use for demos.");
317 }
318
319 #[test]
320 fn rejects_missing_frontmatter() {
321 assert!(parse_frontmatter("# demo\n").is_err());
322 }
323
324 #[test]
325 fn searches_registry_index() {
326 let index = RegistryIndex {
327 skill: vec![
328 IndexedSkill {
329 name: "pdf".to_string(),
330 description: "Work with PDF documents".to_string(),
331 source: "gh:anthropics/skills/skills/pdf".to_string(),
332 tags: vec!["documents".to_string(), "ocr".to_string()],
333 },
334 IndexedSkill {
335 name: "rust-code-review".to_string(),
336 description: "Review Rust code".to_string(),
337 source: "tea:platform/skills/rust-code-review".to_string(),
338 tags: vec!["rust".to_string()],
339 },
340 ],
341 source: Vec::new(),
342 };
343
344 assert_eq!(index.search("pdf").len(), 1);
345 assert_eq!(index.search("documents ocr").len(), 1);
346 assert_eq!(index.search("python").len(), 0);
347 }
348}