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)]
32#[allow(dead_code)]
33struct SkillFrontmatter {
34 name: String,
35 description: String,
36 #[serde(default)]
37 license: Option<String>,
38 #[serde(default)]
39 compatibility: Option<String>,
40 #[serde(default)]
41 metadata: Option<serde_yaml::Mapping>,
42 #[serde(default, rename = "allowed-tools")]
43 allowed_tools: Option<String>,
44}
45
46pub fn read_skill(path: &Path) -> Result<Skill> {
47 if !path.is_dir() {
48 bail!("skill path is not a directory: {}", path.display());
49 }
50
51 let skill_file = path.join("SKILL.md");
52 let contents = fs::read_to_string(&skill_file)
53 .with_context(|| format!("failed to read {}", skill_file.display()))?;
54 let frontmatter = parse_frontmatter(&contents)
55 .with_context(|| format!("failed to parse {}", skill_file.display()))?;
56
57 Ok(Skill {
58 path: path.to_path_buf(),
59 name: frontmatter.name,
60 description: frontmatter.description,
61 })
62}
63
64fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
65 let mut lines = contents.lines();
66 if lines.next() != Some("---") {
67 bail!("SKILL.md must start with YAML frontmatter delimited by ---");
68 }
69
70 let mut yaml = String::new();
71 for line in lines {
72 if line == "---" {
73 let frontmatter = serde_yaml::from_str(&yaml)?;
74 return Ok(frontmatter);
75 }
76 yaml.push_str(line);
77 yaml.push('\n');
78 }
79
80 bail!("SKILL.md frontmatter is missing closing ---");
81}
82
83pub fn validate_skill_metadata(skill: &Skill) -> Result<()> {
94 validate_skill_name(&skill.name)?;
95
96 if skill.description.trim().is_empty() {
97 bail!("description must not be empty");
98 }
99
100 Ok(())
108}
109
110pub fn validate_skill(skill: &Skill) -> Result<()> {
117 validate_skill_metadata(skill)?;
118
119 let dirname = skill
120 .path
121 .file_name()
122 .and_then(|name| name.to_str())
123 .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
124 if dirname != skill.name {
125 bail!(
126 "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
127 skill.name,
128 dirname
129 );
130 }
131
132 Ok(())
133}
134
135pub fn validate_skill_name(name: &str) -> Result<()> {
136 let len = name.chars().count();
137 if len == 0 || len > 64 {
138 bail!("skill name must be 1-64 characters");
139 }
140
141 if name.starts_with('-') || name.ends_with('-') {
142 bail!("skill name must not start or end with a hyphen");
143 }
144
145 if name.contains("--") {
146 bail!("skill name must not contain consecutive hyphens");
147 }
148
149 if !name
150 .chars()
151 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
152 {
153 bail!("skill name may only contain lowercase letters, numbers, and hyphens");
154 }
155
156 Ok(())
157}
158
159pub fn checksum_dir(path: &Path) -> Result<String> {
160 let mut hasher = Sha256::new();
161 for file in collect_files(path)? {
162 let relative_path = file.strip_prefix(path).with_context(|| {
163 format!(
164 "failed to make {} relative to {}",
165 file.display(),
166 path.display()
167 )
168 })?;
169 hasher.update(relative_path.to_string_lossy().as_bytes());
170 hasher.update([0]);
171 hasher
172 .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
173 hasher.update([0]);
174 }
175 Ok(format!("sha256:{:x}", hasher.finalize()))
176}
177
178pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
179 let mut files = Vec::new();
180 collect_files_inner(path, &mut files)?;
181 files.sort();
182 Ok(files)
183}
184
185fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
186 for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
187 let entry = entry?;
188 let path = entry.path();
189 let file_type = entry.file_type()?;
190
191 if file_type.is_dir() {
192 collect_files_inner(&path, files)?;
193 } else if file_type.is_file() {
194 files.push(path);
195 }
196 }
197
198 Ok(())
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct Manifest {
203 pub install: InstallConfig,
204 #[serde(default)]
205 pub skills: BTreeMap<String, String>,
206 #[serde(default)]
207 pub registries: BTreeMap<String, RegistryConfig>,
208}
209
210impl Manifest {
211 pub fn new(target: PathBuf) -> Self {
212 Self {
213 install: InstallConfig { target },
214 skills: BTreeMap::new(),
215 registries: BTreeMap::new(),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize)]
221pub struct InstallConfig {
222 pub target: PathBuf,
223}
224
225pub const LOCKFILE_VERSION: u32 = 1;
234
235fn default_lockfile_version() -> u32 {
236 LOCKFILE_VERSION
237}
238
239#[derive(Debug, Deserialize, Serialize)]
240pub struct Lockfile {
241 #[serde(default = "default_lockfile_version")]
246 pub version: u32,
247 #[serde(default)]
248 pub skill: Vec<LockedSkill>,
249}
250
251impl Default for Lockfile {
252 fn default() -> Self {
253 Self {
254 version: LOCKFILE_VERSION,
255 skill: Vec::new(),
256 }
257 }
258}
259
260impl Lockfile {
261 pub fn ensure_supported_version(&self) -> Result<(), String> {
266 if self.version > LOCKFILE_VERSION {
267 return Err(format!(
268 "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
269 self.version
270 ));
271 }
272 Ok(())
273 }
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize)]
277pub struct LockedSkill {
278 pub name: String,
279 pub source: String,
280 pub resolved: String,
281 pub checksum: String,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
285pub struct RegistryConfig {
286 pub kind: RegistryKind,
287 pub url: String,
288 pub default_ref: String,
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
292#[serde(rename_all = "kebab-case")]
293pub enum RegistryKind {
294 GitHost,
295 Http,
296}
297
298#[derive(Debug, Default, Clone, Deserialize, Serialize)]
299pub struct RegistryIndex {
300 #[serde(default)]
301 pub skill: Vec<IndexedSkill>,
302 #[serde(default)]
303 pub source: Vec<IndexSource>,
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize)]
307pub struct IndexedSkill {
308 pub name: String,
309 pub description: String,
310 pub source: String,
311 #[serde(default)]
312 pub tags: Vec<String>,
313}
314
315#[derive(Debug, Clone, Deserialize, Serialize)]
316pub struct IndexSource {
317 pub source: String,
318 #[serde(default)]
319 pub tags: Vec<String>,
320}
321
322impl RegistryIndex {
323 pub fn validate(&self) -> Result<()> {
324 for skill in &self.skill {
325 skill.validate()?;
326 }
327 for source in &self.source {
328 source.validate()?;
329 }
330 Ok(())
331 }
332
333 pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
334 let terms: Vec<String> = query
335 .split_whitespace()
336 .map(|term| term.to_ascii_lowercase())
337 .collect();
338 if terms.is_empty() {
339 return Vec::new();
340 }
341
342 self.skill
343 .iter()
344 .filter(|skill| {
345 let haystack = skill.search_text();
346 terms.iter().all(|term| haystack.contains(term))
347 })
348 .collect()
349 }
350}
351
352impl IndexSource {
353 pub fn validate(&self) -> Result<()> {
354 if self.source.trim().is_empty() {
355 bail!("indexed source must not be empty");
356 }
357 Ok(())
358 }
359}
360
361impl IndexedSkill {
362 pub fn validate(&self) -> Result<()> {
363 validate_skill_name(&self.name)?;
364 if self.description.trim().is_empty() {
365 bail!("indexed skill description must not be empty: {}", self.name);
366 }
367 if self.source.trim().is_empty() {
368 bail!("indexed skill source must not be empty: {}", self.name);
369 }
370 Ok(())
371 }
372
373 fn search_text(&self) -> String {
374 format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn validates_skill_names() {
384 assert!(validate_skill_name("rust-code-review").is_ok());
385 assert!(validate_skill_name("Rust-Code-Review").is_err());
386 assert!(validate_skill_name("-rust").is_err());
387 assert!(validate_skill_name("rust-").is_err());
388 assert!(validate_skill_name("rust--review").is_err());
389 }
390
391 #[test]
392 fn parses_frontmatter() {
393 let frontmatter =
394 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
395 .expect("frontmatter should parse");
396
397 assert_eq!(frontmatter.name, "demo-skill");
398 assert_eq!(frontmatter.description, "Use for demos.");
399 }
400
401 #[test]
402 fn rejects_missing_frontmatter() {
403 assert!(parse_frontmatter("# demo\n").is_err());
404 }
405
406 #[test]
407 fn tolerates_unknown_frontmatter_fields() {
408 let frontmatter = parse_frontmatter(
412 "---\n\
413 name: agent-browser\n\
414 description: Browser automation.\n\
415 allowed-tools: Bash(agent-browser:*)\n\
416 hidden: true\n\
417 custom-field: arbitrary\n\
418 ---\n",
419 )
420 .expect("foreign fields must be ignored, not rejected");
421 assert_eq!(frontmatter.name, "agent-browser");
422 assert_eq!(frontmatter.description, "Browser automation.");
423 assert_eq!(
424 frontmatter.allowed_tools.as_deref(),
425 Some("Bash(agent-browser:*)")
426 );
427 }
428
429 #[test]
430 fn still_requires_name_and_description() {
431 assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
434 }
435
436 #[test]
437 fn validate_skill_metadata_ignores_directory_mismatch() {
438 let skill = Skill {
445 path: PathBuf::from("/tmp/composition-patterns"),
446 name: "vercel-composition-patterns".to_string(),
447 description: "React composition patterns.".to_string(),
448 };
449 assert!(validate_skill_metadata(&skill).is_ok());
450 assert!(validate_skill(&skill).is_err());
451 }
452
453 #[test]
454 fn accepts_long_descriptions() {
455 let long = "Use when the user is doing things. ".repeat(200);
460 assert!(long.len() > 1024);
461 let skill = Skill {
462 path: PathBuf::from("/tmp/example"),
463 name: "example".to_string(),
464 description: long,
465 };
466 assert!(validate_skill(&skill).is_ok());
467
468 let blank = Skill {
469 path: PathBuf::from("/tmp/example"),
470 name: "example".to_string(),
471 description: " ".to_string(),
472 };
473 assert!(validate_skill(&blank).is_err());
474 }
475
476 #[test]
477 fn searches_registry_index() {
478 let index = RegistryIndex {
479 skill: vec![
480 IndexedSkill {
481 name: "pdf".to_string(),
482 description: "Work with PDF documents".to_string(),
483 source: "gh:anthropics/skills/skills/pdf".to_string(),
484 tags: vec!["documents".to_string(), "ocr".to_string()],
485 },
486 IndexedSkill {
487 name: "rust-code-review".to_string(),
488 description: "Review Rust code".to_string(),
489 source: "tea:platform/skills/rust-code-review".to_string(),
490 tags: vec!["rust".to_string()],
491 },
492 ],
493 source: Vec::new(),
494 };
495
496 assert_eq!(index.search("pdf").len(), 1);
497 assert_eq!(index.search("documents ocr").len(), 1);
498 assert_eq!(index.search("python").len(), 0);
499 }
500}