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(skill: &Skill) -> Result<()> {
84 validate_skill_name(&skill.name)?;
85
86 let dirname = skill
87 .path
88 .file_name()
89 .and_then(|name| name.to_str())
90 .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
91 if dirname != skill.name {
92 bail!(
93 "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
94 skill.name,
95 dirname
96 );
97 }
98
99 if skill.description.trim().is_empty() {
100 bail!("description must not be empty");
101 }
102
103 if skill.description.chars().count() > 1024 {
104 bail!("description must be at most 1024 characters");
105 }
106
107 Ok(())
108}
109
110pub fn validate_skill_name(name: &str) -> Result<()> {
111 let len = name.chars().count();
112 if len == 0 || len > 64 {
113 bail!("skill name must be 1-64 characters");
114 }
115
116 if name.starts_with('-') || name.ends_with('-') {
117 bail!("skill name must not start or end with a hyphen");
118 }
119
120 if name.contains("--") {
121 bail!("skill name must not contain consecutive hyphens");
122 }
123
124 if !name
125 .chars()
126 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
127 {
128 bail!("skill name may only contain lowercase letters, numbers, and hyphens");
129 }
130
131 Ok(())
132}
133
134pub fn checksum_dir(path: &Path) -> Result<String> {
135 let mut hasher = Sha256::new();
136 for file in collect_files(path)? {
137 let relative_path = file.strip_prefix(path).with_context(|| {
138 format!(
139 "failed to make {} relative to {}",
140 file.display(),
141 path.display()
142 )
143 })?;
144 hasher.update(relative_path.to_string_lossy().as_bytes());
145 hasher.update([0]);
146 hasher
147 .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
148 hasher.update([0]);
149 }
150 Ok(format!("sha256:{:x}", hasher.finalize()))
151}
152
153pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
154 let mut files = Vec::new();
155 collect_files_inner(path, &mut files)?;
156 files.sort();
157 Ok(files)
158}
159
160fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
161 for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
162 let entry = entry?;
163 let path = entry.path();
164 let file_type = entry.file_type()?;
165
166 if file_type.is_dir() {
167 collect_files_inner(&path, files)?;
168 } else if file_type.is_file() {
169 files.push(path);
170 }
171 }
172
173 Ok(())
174}
175
176#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct Manifest {
178 pub install: InstallConfig,
179 #[serde(default)]
180 pub skills: BTreeMap<String, String>,
181 #[serde(default)]
182 pub registries: BTreeMap<String, RegistryConfig>,
183}
184
185impl Manifest {
186 pub fn new(target: PathBuf) -> Self {
187 Self {
188 install: InstallConfig { target },
189 skills: BTreeMap::new(),
190 registries: BTreeMap::new(),
191 }
192 }
193}
194
195#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct InstallConfig {
197 pub target: PathBuf,
198}
199
200pub const LOCKFILE_VERSION: u32 = 1;
209
210fn default_lockfile_version() -> u32 {
211 LOCKFILE_VERSION
212}
213
214#[derive(Debug, Deserialize, Serialize)]
215pub struct Lockfile {
216 #[serde(default = "default_lockfile_version")]
221 pub version: u32,
222 #[serde(default)]
223 pub skill: Vec<LockedSkill>,
224}
225
226impl Default for Lockfile {
227 fn default() -> Self {
228 Self {
229 version: LOCKFILE_VERSION,
230 skill: Vec::new(),
231 }
232 }
233}
234
235impl Lockfile {
236 pub fn ensure_supported_version(&self) -> Result<(), String> {
241 if self.version > LOCKFILE_VERSION {
242 return Err(format!(
243 "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
244 self.version
245 ));
246 }
247 Ok(())
248 }
249}
250
251#[derive(Debug, Clone, Deserialize, Serialize)]
252pub struct LockedSkill {
253 pub name: String,
254 pub source: String,
255 pub resolved: String,
256 pub checksum: String,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
260pub struct RegistryConfig {
261 pub kind: RegistryKind,
262 pub url: String,
263 pub default_ref: String,
264}
265
266#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "kebab-case")]
268pub enum RegistryKind {
269 GitHost,
270 Http,
271}
272
273#[derive(Debug, Default, Clone, Deserialize, Serialize)]
274pub struct RegistryIndex {
275 #[serde(default)]
276 pub skill: Vec<IndexedSkill>,
277 #[serde(default)]
278 pub source: Vec<IndexSource>,
279}
280
281#[derive(Debug, Clone, Deserialize, Serialize)]
282pub struct IndexedSkill {
283 pub name: String,
284 pub description: String,
285 pub source: String,
286 #[serde(default)]
287 pub tags: Vec<String>,
288}
289
290#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct IndexSource {
292 pub source: String,
293 #[serde(default)]
294 pub tags: Vec<String>,
295}
296
297impl RegistryIndex {
298 pub fn validate(&self) -> Result<()> {
299 for skill in &self.skill {
300 skill.validate()?;
301 }
302 for source in &self.source {
303 source.validate()?;
304 }
305 Ok(())
306 }
307
308 pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
309 let terms: Vec<String> = query
310 .split_whitespace()
311 .map(|term| term.to_ascii_lowercase())
312 .collect();
313 if terms.is_empty() {
314 return Vec::new();
315 }
316
317 self.skill
318 .iter()
319 .filter(|skill| {
320 let haystack = skill.search_text();
321 terms.iter().all(|term| haystack.contains(term))
322 })
323 .collect()
324 }
325}
326
327impl IndexSource {
328 pub fn validate(&self) -> Result<()> {
329 if self.source.trim().is_empty() {
330 bail!("indexed source must not be empty");
331 }
332 Ok(())
333 }
334}
335
336impl IndexedSkill {
337 pub fn validate(&self) -> Result<()> {
338 validate_skill_name(&self.name)?;
339 if self.description.trim().is_empty() {
340 bail!("indexed skill description must not be empty: {}", self.name);
341 }
342 if self.source.trim().is_empty() {
343 bail!("indexed skill source must not be empty: {}", self.name);
344 }
345 Ok(())
346 }
347
348 fn search_text(&self) -> String {
349 format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn validates_skill_names() {
359 assert!(validate_skill_name("rust-code-review").is_ok());
360 assert!(validate_skill_name("Rust-Code-Review").is_err());
361 assert!(validate_skill_name("-rust").is_err());
362 assert!(validate_skill_name("rust-").is_err());
363 assert!(validate_skill_name("rust--review").is_err());
364 }
365
366 #[test]
367 fn parses_frontmatter() {
368 let frontmatter =
369 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
370 .expect("frontmatter should parse");
371
372 assert_eq!(frontmatter.name, "demo-skill");
373 assert_eq!(frontmatter.description, "Use for demos.");
374 }
375
376 #[test]
377 fn rejects_missing_frontmatter() {
378 assert!(parse_frontmatter("# demo\n").is_err());
379 }
380
381 #[test]
382 fn tolerates_unknown_frontmatter_fields() {
383 let frontmatter = parse_frontmatter(
387 "---\n\
388 name: agent-browser\n\
389 description: Browser automation.\n\
390 allowed-tools: Bash(agent-browser:*)\n\
391 hidden: true\n\
392 custom-field: arbitrary\n\
393 ---\n",
394 )
395 .expect("foreign fields must be ignored, not rejected");
396 assert_eq!(frontmatter.name, "agent-browser");
397 assert_eq!(frontmatter.description, "Browser automation.");
398 assert_eq!(
399 frontmatter.allowed_tools.as_deref(),
400 Some("Bash(agent-browser:*)")
401 );
402 }
403
404 #[test]
405 fn still_requires_name_and_description() {
406 assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
409 }
410
411 #[test]
412 fn searches_registry_index() {
413 let index = RegistryIndex {
414 skill: vec![
415 IndexedSkill {
416 name: "pdf".to_string(),
417 description: "Work with PDF documents".to_string(),
418 source: "gh:anthropics/skills/skills/pdf".to_string(),
419 tags: vec!["documents".to_string(), "ocr".to_string()],
420 },
421 IndexedSkill {
422 name: "rust-code-review".to_string(),
423 description: "Review Rust code".to_string(),
424 source: "tea:platform/skills/rust-code-review".to_string(),
425 tags: vec!["rust".to_string()],
426 },
427 ],
428 source: Vec::new(),
429 };
430
431 assert_eq!(index.search("pdf").len(), 1);
432 assert_eq!(index.search("documents ocr").len(), 1);
433 assert_eq!(index.search("python").len(), 0);
434 }
435}