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 {
214 target,
215 default_registry: None,
216 },
217 skills: BTreeMap::new(),
218 registries: BTreeMap::new(),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct InstallConfig {
225 pub target: PathBuf,
226
227 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub default_registry: Option<String>,
243}
244
245pub const LOCKFILE_VERSION: u32 = 2;
264
265fn default_lockfile_version() -> u32 {
266 1
271}
272
273#[derive(Debug, Deserialize, Serialize)]
274pub struct Lockfile {
275 #[serde(default = "default_lockfile_version")]
280 pub version: u32,
281 #[serde(default)]
282 pub skill: Vec<LockedSkill>,
283}
284
285impl Default for Lockfile {
286 fn default() -> Self {
287 Self {
288 version: LOCKFILE_VERSION,
289 skill: Vec::new(),
290 }
291 }
292}
293
294impl Lockfile {
295 pub fn ensure_supported_version(&self) -> Result<(), String> {
300 if self.version > LOCKFILE_VERSION {
301 return Err(format!(
302 "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
303 self.version
304 ));
305 }
306 Ok(())
307 }
308}
309
310#[derive(Debug, Clone, Deserialize, Serialize)]
311pub struct LockedSkill {
312 pub name: String,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub namespace: Option<String>,
321
322 pub source: String,
323 pub resolved: String,
324 pub checksum: String,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
328pub struct RegistryConfig {
329 pub kind: RegistryKind,
330 pub url: String,
331 pub default_ref: String,
332}
333
334#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum RegistryKind {
337 GitHost,
338 Http,
339}
340
341#[derive(Debug, Default, Clone, Deserialize, Serialize)]
342pub struct RegistryIndex {
343 #[serde(default)]
344 pub skill: Vec<IndexedSkill>,
345 #[serde(default)]
346 pub source: Vec<IndexSource>,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize)]
350pub struct IndexedSkill {
351 pub name: String,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub namespace: Option<String>,
363
364 pub description: String,
365
366 pub source: String,
371
372 #[serde(default)]
373 pub tags: Vec<String>,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub score: Option<f64>,
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize)]
387pub struct IndexSource {
388 pub source: String,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub namespace: Option<String>,
400
401 #[serde(default)]
402 pub tags: Vec<String>,
403}
404
405impl RegistryIndex {
406 pub fn validate(&self) -> Result<()> {
407 for skill in &self.skill {
408 skill.validate()?;
409 }
410 for source in &self.source {
411 source.validate()?;
412 }
413 Ok(())
414 }
415
416 pub fn search(&self, query: &str) -> Vec<(&IndexedSkill, f64)> {
427 let terms: Vec<String> = query
428 .split_whitespace()
429 .map(|term| term.to_ascii_lowercase())
430 .collect();
431 if terms.is_empty() {
432 return Vec::new();
433 }
434
435 let mut scored: Vec<(&IndexedSkill, f64)> = self
436 .skill
437 .iter()
438 .filter_map(|skill| skill.match_score(&terms).map(|score| (skill, score)))
439 .collect();
440
441 scored.sort_by(|(a, a_score), (b, b_score)| {
442 b_score
443 .partial_cmp(a_score)
444 .unwrap_or(std::cmp::Ordering::Equal)
445 .then_with(|| a.qualified_name().cmp(&b.qualified_name()))
446 });
447 scored
448 }
449}
450
451impl IndexSource {
452 pub fn validate(&self) -> Result<()> {
453 if self.source.trim().is_empty() {
454 bail!("indexed source must not be empty");
455 }
456 Ok(())
457 }
458}
459
460impl IndexedSkill {
461 pub fn validate(&self) -> Result<()> {
462 validate_skill_name(&self.name)?;
463 if let Some(ns) = &self.namespace {
464 validate_skill_name(ns).map_err(|err| anyhow!("invalid namespace: {err}"))?;
468 }
469 if self.description.trim().is_empty() {
470 bail!("indexed skill description must not be empty: {}", self.name);
471 }
472 if self.source.trim().is_empty() {
473 bail!("indexed skill source must not be empty: {}", self.name);
474 }
475 Ok(())
476 }
477
478 pub fn qualified_name(&self) -> String {
483 match &self.namespace {
484 Some(ns) => format!("{ns}/{}", self.name),
485 None => self.name.clone(),
486 }
487 }
488
489 fn field_weight(term: &str, field: &str, is_name_or_tag: bool) -> f64 {
497 if field.is_empty() {
498 return 0.0;
499 }
500 if is_name_or_tag {
501 if field == term {
502 return 4.0;
503 }
504 if field.starts_with(term) {
505 return 3.0;
506 }
507 if word_boundary_match(field, term) {
508 return 2.0;
509 }
510 }
511 if field.contains(term) {
512 if is_name_or_tag { 1.0 } else { 0.5 }
513 } else {
514 0.0
515 }
516 }
517
518 fn match_score(&self, terms: &[String]) -> Option<f64> {
523 let name = self.name.to_ascii_lowercase();
524 let namespace = self
525 .namespace
526 .as_deref()
527 .map(|ns| ns.to_ascii_lowercase())
528 .unwrap_or_default();
529 let description = self.description.to_ascii_lowercase();
530 let tags: Vec<String> = self.tags.iter().map(|t| t.to_ascii_lowercase()).collect();
531
532 let mut total = 0.0;
533 for term in terms {
534 let mut best = Self::field_weight(term, &name, true);
535 best = best.max(Self::field_weight(term, &namespace, true));
536 for tag in &tags {
537 best = best.max(Self::field_weight(term, tag, true));
538 }
539 best = best.max(Self::field_weight(term, &description, false));
540
541 if best <= 0.0 {
542 return None;
543 }
544 total += best;
545 }
546 Some(total)
547 }
548}
549
550fn word_boundary_match(field: &str, term: &str) -> bool {
555 let mut start = 0;
556 while let Some(idx) = field[start..].find(term) {
557 let match_start = start + idx;
558 let match_end = match_start + term.len();
559 let before_ok = match_start == 0
560 || !field[..match_start]
561 .chars()
562 .next_back()
563 .is_some_and(|c| c.is_alphanumeric());
564 let after_ok = match_end == field.len()
565 || !field[match_end..]
566 .chars()
567 .next()
568 .is_some_and(|c| c.is_alphanumeric());
569 if before_ok && after_ok {
570 return true;
571 }
572 start = match_start + 1;
573 if start >= field.len() {
574 break;
575 }
576 }
577 false
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn validates_skill_names() {
586 assert!(validate_skill_name("rust-code-review").is_ok());
587 assert!(validate_skill_name("Rust-Code-Review").is_err());
588 assert!(validate_skill_name("-rust").is_err());
589 assert!(validate_skill_name("rust-").is_err());
590 assert!(validate_skill_name("rust--review").is_err());
591 }
592
593 #[test]
594 fn parses_frontmatter() {
595 let frontmatter =
596 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
597 .expect("frontmatter should parse");
598
599 assert_eq!(frontmatter.name, "demo-skill");
600 assert_eq!(frontmatter.description, "Use for demos.");
601 }
602
603 #[test]
604 fn rejects_missing_frontmatter() {
605 assert!(parse_frontmatter("# demo\n").is_err());
606 }
607
608 #[test]
609 fn tolerates_unknown_frontmatter_fields() {
610 let frontmatter = parse_frontmatter(
614 "---\n\
615 name: agent-browser\n\
616 description: Browser automation.\n\
617 allowed-tools: Bash(agent-browser:*)\n\
618 hidden: true\n\
619 custom-field: arbitrary\n\
620 ---\n",
621 )
622 .expect("foreign fields must be ignored, not rejected");
623 assert_eq!(frontmatter.name, "agent-browser");
624 assert_eq!(frontmatter.description, "Browser automation.");
625 assert_eq!(
626 frontmatter.allowed_tools.as_deref(),
627 Some("Bash(agent-browser:*)")
628 );
629 }
630
631 #[test]
632 fn still_requires_name_and_description() {
633 assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
636 }
637
638 #[test]
639 fn validate_skill_metadata_ignores_directory_mismatch() {
640 let skill = Skill {
647 path: PathBuf::from("/tmp/composition-patterns"),
648 name: "vercel-composition-patterns".to_string(),
649 description: "React composition patterns.".to_string(),
650 };
651 assert!(validate_skill_metadata(&skill).is_ok());
652 assert!(validate_skill(&skill).is_err());
653 }
654
655 #[test]
656 fn accepts_long_descriptions() {
657 let long = "Use when the user is doing things. ".repeat(200);
662 assert!(long.len() > 1024);
663 let skill = Skill {
664 path: PathBuf::from("/tmp/example"),
665 name: "example".to_string(),
666 description: long,
667 };
668 assert!(validate_skill(&skill).is_ok());
669
670 let blank = Skill {
671 path: PathBuf::from("/tmp/example"),
672 name: "example".to_string(),
673 description: " ".to_string(),
674 };
675 assert!(validate_skill(&blank).is_err());
676 }
677
678 #[test]
679 fn searches_registry_index() {
680 let index = RegistryIndex {
681 skill: vec![
682 IndexedSkill {
683 name: "pdf".to_string(),
684 namespace: Some("anthropics".to_string()),
685 description: "Work with PDF documents".to_string(),
686 source: "anthropics/pdf".to_string(),
687 tags: vec!["documents".to_string(), "ocr".to_string()],
688 score: None,
689 },
690 IndexedSkill {
691 name: "rust-code-review".to_string(),
692 namespace: None,
693 description: "Review Rust code".to_string(),
694 source: "rust-code-review".to_string(),
695 tags: vec!["rust".to_string()],
696 score: None,
697 },
698 ],
699 source: Vec::new(),
700 };
701
702 assert_eq!(index.search("pdf").len(), 1);
703 assert_eq!(index.search("documents ocr").len(), 1);
704 assert_eq!(index.search("python").len(), 0);
705 assert_eq!(index.search("anthropics").len(), 1);
708 }
709
710 #[test]
711 fn ranks_name_matches_above_description_only_matches() {
712 let index = RegistryIndex {
718 skill: vec![
719 IndexedSkill {
720 name: "changelog-writer".to_string(),
721 namespace: None,
722 description: "Summarize commits, including ones touching Rust code."
723 .to_string(),
724 source: "changelog-writer".to_string(),
725 tags: vec![],
726 score: None,
727 },
728 IndexedSkill {
729 name: "rust-code-review".to_string(),
730 namespace: None,
731 description: "Review code for correctness".to_string(),
732 source: "rust-code-review".to_string(),
733 tags: vec!["rust".to_string()],
734 score: None,
735 },
736 ],
737 source: Vec::new(),
738 };
739
740 let results = index.search("rust");
741 assert_eq!(results.len(), 2);
742 assert_eq!(results[0].0.name, "rust-code-review");
743 assert!(results[0].1 > results[1].1);
744 }
745
746 #[test]
747 fn requires_every_term_to_match_somewhere() {
748 let index = RegistryIndex {
751 skill: vec![IndexedSkill {
752 name: "pdf".to_string(),
753 namespace: Some("anthropics".to_string()),
754 description: "Work with PDF documents".to_string(),
755 source: "anthropics/pdf".to_string(),
756 tags: vec!["documents".to_string()],
757 score: None,
758 }],
759 source: Vec::new(),
760 };
761
762 assert_eq!(index.search("pdf python").len(), 0);
763 assert_eq!(index.search("pdf documents").len(), 1);
764 }
765
766 #[test]
767 fn ties_break_alphabetically_by_qualified_name() {
768 let index = RegistryIndex {
769 skill: vec![
770 IndexedSkill {
771 name: "zeta".to_string(),
772 namespace: None,
773 description: "docs helper".to_string(),
774 source: "zeta".to_string(),
775 tags: vec![],
776 score: None,
777 },
778 IndexedSkill {
779 name: "alpha".to_string(),
780 namespace: None,
781 description: "docs helper".to_string(),
782 source: "alpha".to_string(),
783 tags: vec![],
784 score: None,
785 },
786 ],
787 source: Vec::new(),
788 };
789
790 let results = index.search("docs");
791 assert_eq!(results[0].0.name, "alpha");
792 assert_eq!(results[1].0.name, "zeta");
793 }
794
795 #[test]
796 fn qualified_name_round_trips() {
797 let scoped = IndexedSkill {
798 name: "pdf".to_string(),
799 namespace: Some("anthropics".to_string()),
800 description: "x".to_string(),
801 source: "anthropics/pdf".to_string(),
802 tags: vec![],
803 score: None,
804 };
805 assert_eq!(scoped.qualified_name(), "anthropics/pdf");
806
807 let unscoped = IndexedSkill {
808 name: "legacy".to_string(),
809 namespace: None,
810 description: "x".to_string(),
811 source: "legacy".to_string(),
812 tags: vec![],
813 score: None,
814 };
815 assert_eq!(unscoped.qualified_name(), "legacy");
816 }
817
818 #[test]
819 fn validates_namespace_charset() {
820 let mut skill = IndexedSkill {
822 name: "ok".to_string(),
823 namespace: Some("good-ns".to_string()),
824 description: "x".to_string(),
825 source: "good-ns/ok".to_string(),
826 tags: vec![],
827 score: None,
828 };
829 assert!(skill.validate().is_ok());
830
831 skill.namespace = Some("Bad_Namespace".to_string());
832 let err = skill.validate().unwrap_err().to_string();
833 assert!(err.contains("invalid namespace"), "got: {err}");
834 }
835
836 #[test]
837 fn parses_v1_lockfile_without_version_or_namespace() {
838 let toml_v1 = r#"
842[[skill]]
843name = "pdf"
844source = "public:pdf"
845resolved = "http+knack:https://example.com/skills/pdf/archive#sha=abc123"
846checksum = "sha256:deadbeef"
847"#;
848 let lockfile: Lockfile = toml::from_str(toml_v1).expect("v1 lockfile must parse");
849 assert_eq!(lockfile.version, 1);
850 assert_eq!(lockfile.skill.len(), 1);
851 assert_eq!(lockfile.skill[0].namespace, None);
852 assert!(lockfile.ensure_supported_version().is_ok());
853 }
854
855 #[test]
856 fn parses_v2_lockfile_with_namespace() {
857 let toml_v2 = r#"
858version = 2
859
860[[skill]]
861name = "pdf"
862namespace = "anthropics"
863source = "public:anthropics/pdf"
864resolved = "http+knack:https://example.com/skills/anthropics/pdf/archive#sha=abc"
865checksum = "sha256:deadbeef"
866"#;
867 let lockfile: Lockfile = toml::from_str(toml_v2).expect("v2 lockfile must parse");
868 assert_eq!(lockfile.version, 2);
869 assert_eq!(lockfile.skill[0].namespace.as_deref(), Some("anthropics"));
870 }
871
872 #[test]
873 fn rejects_lockfile_from_newer_knack() {
874 let future = r#"
875version = 999
876[[skill]]
877name = "pdf"
878source = "public:pdf"
879resolved = "x"
880checksum = "x"
881"#;
882 let lockfile: Lockfile = toml::from_str(future).unwrap();
883 let err = lockfile
884 .ensure_supported_version()
885 .expect_err("future lockfile must be rejected");
886 assert!(err.contains("newer than this knack supports"), "got: {err}");
887 }
888
889 #[test]
890 fn locked_skill_omits_namespace_when_absent() {
891 let skill = LockedSkill {
892 name: "pdf".to_string(),
893 namespace: None,
894 source: "public:pdf".to_string(),
895 resolved: "x".to_string(),
896 checksum: "y".to_string(),
897 };
898 let serialized = toml::to_string(&skill).unwrap();
899 assert!(
900 !serialized.contains("namespace"),
901 "namespace should be omitted from legacy entries, got: {serialized}"
902 );
903 }
904
905 #[test]
906 fn parses_legacy_unnamespaced_index_json() {
907 let json = r#"{
911 "name": "pdf",
912 "description": "PDF docs",
913 "source": "public:pdf",
914 "tags": ["documents"]
915 }"#;
916 let parsed: IndexedSkill =
917 serde_json::from_str(json).expect("legacy index.json must parse");
918 assert_eq!(parsed.name, "pdf");
919 assert_eq!(parsed.namespace, None);
920 assert_eq!(parsed.qualified_name(), "pdf");
921 }
922
923 #[test]
924 fn omits_namespace_field_when_absent_on_serialize() {
925 let skill = IndexedSkill {
930 name: "legacy".to_string(),
931 namespace: None,
932 description: "x".to_string(),
933 source: "legacy".to_string(),
934 tags: vec![],
935 score: None,
936 };
937 let json = serde_json::to_string(&skill).unwrap();
938 assert!(
939 !json.contains("namespace"),
940 "namespace should be omitted, got: {json}"
941 );
942 }
943}