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
188pub const LOCKFILE_VERSION: u32 = 1;
197
198fn default_lockfile_version() -> u32 {
199 LOCKFILE_VERSION
200}
201
202#[derive(Debug, Deserialize, Serialize)]
203pub struct Lockfile {
204 #[serde(default = "default_lockfile_version")]
209 pub version: u32,
210 #[serde(default)]
211 pub skill: Vec<LockedSkill>,
212}
213
214impl Default for Lockfile {
215 fn default() -> Self {
216 Self {
217 version: LOCKFILE_VERSION,
218 skill: Vec::new(),
219 }
220 }
221}
222
223impl Lockfile {
224 pub fn ensure_supported_version(&self) -> Result<(), String> {
229 if self.version > LOCKFILE_VERSION {
230 return Err(format!(
231 "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
232 self.version
233 ));
234 }
235 Ok(())
236 }
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct LockedSkill {
241 pub name: String,
242 pub source: String,
243 pub resolved: String,
244 pub checksum: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
248pub struct RegistryConfig {
249 pub kind: RegistryKind,
250 pub url: String,
251 pub default_ref: String,
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
255#[serde(rename_all = "kebab-case")]
256pub enum RegistryKind {
257 GitHost,
258 Http,
259}
260
261#[derive(Debug, Default, Clone, Deserialize, Serialize)]
262pub struct RegistryIndex {
263 #[serde(default)]
264 pub skill: Vec<IndexedSkill>,
265 #[serde(default)]
266 pub source: Vec<IndexSource>,
267}
268
269#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct IndexedSkill {
271 pub name: String,
272 pub description: String,
273 pub source: String,
274 #[serde(default)]
275 pub tags: Vec<String>,
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct IndexSource {
280 pub source: String,
281 #[serde(default)]
282 pub tags: Vec<String>,
283}
284
285impl RegistryIndex {
286 pub fn validate(&self) -> Result<()> {
287 for skill in &self.skill {
288 skill.validate()?;
289 }
290 for source in &self.source {
291 source.validate()?;
292 }
293 Ok(())
294 }
295
296 pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
297 let terms: Vec<String> = query
298 .split_whitespace()
299 .map(|term| term.to_ascii_lowercase())
300 .collect();
301 if terms.is_empty() {
302 return Vec::new();
303 }
304
305 self.skill
306 .iter()
307 .filter(|skill| {
308 let haystack = skill.search_text();
309 terms.iter().all(|term| haystack.contains(term))
310 })
311 .collect()
312 }
313}
314
315impl IndexSource {
316 pub fn validate(&self) -> Result<()> {
317 if self.source.trim().is_empty() {
318 bail!("indexed source must not be empty");
319 }
320 Ok(())
321 }
322}
323
324impl IndexedSkill {
325 pub fn validate(&self) -> Result<()> {
326 validate_skill_name(&self.name)?;
327 if self.description.trim().is_empty() {
328 bail!("indexed skill description must not be empty: {}", self.name);
329 }
330 if self.source.trim().is_empty() {
331 bail!("indexed skill source must not be empty: {}", self.name);
332 }
333 Ok(())
334 }
335
336 fn search_text(&self) -> String {
337 format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn validates_skill_names() {
347 assert!(validate_skill_name("rust-code-review").is_ok());
348 assert!(validate_skill_name("Rust-Code-Review").is_err());
349 assert!(validate_skill_name("-rust").is_err());
350 assert!(validate_skill_name("rust-").is_err());
351 assert!(validate_skill_name("rust--review").is_err());
352 }
353
354 #[test]
355 fn parses_frontmatter() {
356 let frontmatter =
357 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
358 .expect("frontmatter should parse");
359
360 assert_eq!(frontmatter.name, "demo-skill");
361 assert_eq!(frontmatter.description, "Use for demos.");
362 }
363
364 #[test]
365 fn rejects_missing_frontmatter() {
366 assert!(parse_frontmatter("# demo\n").is_err());
367 }
368
369 #[test]
370 fn searches_registry_index() {
371 let index = RegistryIndex {
372 skill: vec![
373 IndexedSkill {
374 name: "pdf".to_string(),
375 description: "Work with PDF documents".to_string(),
376 source: "gh:anthropics/skills/skills/pdf".to_string(),
377 tags: vec!["documents".to_string(), "ocr".to_string()],
378 },
379 IndexedSkill {
380 name: "rust-code-review".to_string(),
381 description: "Review Rust code".to_string(),
382 source: "tea:platform/skills/rust-code-review".to_string(),
383 tags: vec!["rust".to_string()],
384 },
385 ],
386 source: Vec::new(),
387 };
388
389 assert_eq!(index.search("pdf").len(), 1);
390 assert_eq!(index.search("documents ocr").len(), 1);
391 assert_eq!(index.search("python").len(), 0);
392 }
393}