Skip to main content

knack_core/
lib.rs

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/// Agent Skills frontmatter knack understands. Unknown fields are
19/// silently ignored: the SKILL.md format is shared across tooling
20/// (Anthropic's catalog, third-party agents, internal extensions),
21/// and other tools write fields like `hidden` that knack has no use
22/// for. Rejecting them broke `knack list` whenever a foreign skill
23/// landed in `.agents/skills/` — the user couldn't list any skill
24/// just because one of them had an extra field.
25///
26/// Required fields (`name`, `description`) still fail loudly on
27/// typos: serde reports "missing field `description`" rather than
28/// silently accepting `desciption`. Typos in optional fields will
29/// silently be dropped, which is the usual YAML/serde convention
30/// and the acceptable tradeoff for ecosystem interop.
31#[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
83/// Validate a skill's identity (name well-formed, description present)
84/// without requiring the on-disk directory to match the frontmatter
85/// name. Use this when reading skills from an upstream source whose
86/// layout we don't control — e.g. when materialising a registry's
87/// dynamic `[[source]]` entries against third-party repos. Vendors
88/// commonly use unprefixed directory names with brand-prefixed
89/// frontmatter names (e.g. `skills/composition-patterns/` containing
90/// `name: vercel-composition-patterns`); that's a legitimate
91/// convention and the registry's archive flow already renames on
92/// the way out using the frontmatter name.
93pub 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    // We previously rejected descriptions over 1024 characters, but
101    // real-world Agent Skills (notably anthropics/skills) include
102    // multi-paragraph "use when..." prose well past that ceiling
103    // (skill-creator runs to ~5200 chars). knack should not gatekeep
104    // the format the broader ecosystem uses; UIs are free to truncate
105    // long descriptions at display time. Required fields stay strict.
106
107    Ok(())
108}
109
110/// Strict validation for skills installed locally or being authored.
111/// In addition to the metadata checks, the on-disk directory name
112/// must match the frontmatter name — this is a local-filesystem
113/// invariant for `.agents/skills/<name>/` lookup. Use this from
114/// `knack install`, `knack new`, `knack validate`, and the publish
115/// flow.
116pub 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    /// Registry alias used to resolve bare install sources that omit
228    /// the `<registry>:` prefix. Set via `knack init` (defaults to
229    /// `"public"` when the public registry is seeded) or by hand.
230    ///
231    /// With this set to `"public"`, `knack add anthropics/pdf`
232    /// resolves the same as `knack add public:anthropics/pdf`, and
233    /// `knack add pdf` resolves as `knack add public:pdf` (which the
234    /// registry then soft-resolves under its own namespace via
235    /// X-Knack-Namespace). None means "no implicit default"; the CLI
236    /// falls back to auto-defaulting when exactly one registry is
237    /// configured, and errors on ambiguity otherwise.
238    ///
239    /// Layered through the system → global → project scopes like
240    /// `[registries.*]`, last-write-wins.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub default_registry: Option<String>,
243}
244
245/// Current lockfile schema version. Bump when the on-disk layout
246/// changes in a way an older `knack` couldn't safely interpret.
247///
248/// Backward compatibility is one-way: a new knack reading an old
249/// lockfile should keep working (with default values for new fields).
250/// An old knack reading a new lockfile errors loudly rather than
251/// guessing — that's what `Lockfile::ensure_supported_version`
252/// enforces.
253///
254/// Version history
255/// - **v1**: initial layout. `name`, `source`, `resolved`, `checksum`.
256///   Lockfiles with no `version` field at all are also v1 (the field
257///   only became required when v2 landed).
258/// - **v2**: adds optional `namespace` per locked skill so namespaced
259///   registries (`public:anthropics/pdf`) can round-trip through the
260///   lockfile without losing the vendor scope. Old v1 entries with no
261///   `namespace` field continue to read fine into v2; new writes emit
262///   `version = 2` and include the field when scoped.
263pub const LOCKFILE_VERSION: u32 = 2;
264
265fn default_lockfile_version() -> u32 {
266    // Missing-version means "written before this field existed" → v1
267    // by definition, not the current latest. Without this we'd
268    // silently promote untouched v1 files to whatever LOCKFILE_VERSION
269    // happens to be today, masking actual version skew.
270    1
271}
272
273#[derive(Debug, Deserialize, Serialize)]
274pub struct Lockfile {
275    /// Schema version. Older lockfiles without this field default to
276    /// version 1 since v1 is identical to the pre-versioned layout —
277    /// only the SHA-pinning convention differs, and that's transparent
278    /// to the parser.
279    #[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    /// Refuse to operate on a lockfile from a future knack version.
296    /// New schema versions may add fields or change semantics in ways
297    /// this binary can't preserve on round-trip; bailing avoids
298    /// silently corrupting the file when we write it back.
299    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    /// Vendor scope for namespaced registries (lockfile v2+). None
315    /// for legacy unscoped entries written by knack 0.2.x or skills
316    /// installed from unnamespaced sources (gh:/git+/local paths).
317    /// `skip_serializing_if = "Option::is_none"` keeps legacy entries
318    /// from gaining a noisy `namespace = ""` on round-trip.
319    #[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    /// Vendor-scoping prefix added at materialize time so two skills
354    /// sharing a bare name (e.g. `find-skills` exists in both
355    /// vercel-labs/skills and ajac-zero/knack) can coexist in one
356    /// registry without colliding. None means "unscoped" — supported
357    /// for backward compatibility with pre-namespacing index files;
358    /// new entries written by `knack-registry build-static` or
359    /// `materialize` always carry one. Validated against the same
360    /// kebab-case rules as `name`.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub namespace: Option<String>,
363
364    pub description: String,
365
366    /// The install command suffix that follows `<registry>:`. For
367    /// namespaced entries this is `<namespace>/<name>`; for legacy
368    /// unscoped entries it's just `<name>`. Always matches what
369    /// `qualified_name()` returns.
370    pub source: String,
371
372    #[serde(default)]
373    pub tags: Vec<String>,
374
375    /// Relevance score assigned by `RegistryIndex::search`. Only ever
376    /// set on results returned from a search — never persisted in
377    /// `knack.index.toml` and never present on entries read from
378    /// `/index`, so this is skipped on serialization whenever `None`.
379    /// Kept `#[serde(default)]` on the way in so index files written
380    /// before this field existed (and any registry that hasn't
381    /// upgraded yet) still deserialize cleanly.
382    #[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    /// Optional explicit namespace override for skills materialised
391    /// from this source. When set, all skills walked under this
392    /// source's tree get scoped as `<namespace>/<skill-name>`. When
393    /// omitted, knack-registry derives a namespace from the source
394    /// URL itself (typically the gh:owner segment). The override
395    /// matters for cases like `gh:ajac-zero/knack/skills` where the
396    /// owner segment ("ajac-zero") isn't the brand we want users to
397    /// install under ("knack"). Same kebab-case rules as skill names.
398    #[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    /// Scores and ranks skills against a whitespace-separated query.
417    /// Every term must match *somewhere* in a skill (name, namespace,
418    /// description, or tags) for the skill to be included at all —
419    /// same AND semantics as before. What's new is *where* a term
420    /// matched now affects ranking: a hit in the name or tags counts
421    /// for much more than a hit buried in the description, so a
422    /// query term that merely appears in unrelated prose no longer
423    /// ranks a skill as highly as one that's actually about that
424    /// term. Results are sorted best-match-first (ties broken
425    /// alphabetically by `qualified_name()` for stable output).
426    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            // Namespaces use the same character set as skill names —
465            // kebab-case for URL-safe round-tripping through
466            // `/skills/<ns>/<name>/archive`.
467            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    /// `<namespace>/<name>` when scoped, bare `<name>` otherwise. This
479    /// is the on-the-wire identifier — what comes after `<registry>:`
480    /// in install commands, and what's used as the archive URL path
481    /// segment.
482    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    /// Scores a single already-lowercased term against one field,
490    /// choosing the highest tier the term qualifies for in that
491    /// field. `is_name_or_tag` widens the "whole word" and "starts
492    /// with" tiers to also apply to tags/namespace, since those are
493    /// short identifier-like strings where a whole-word hit is just
494    /// as meaningful as an exact name match, unlike the free-form
495    /// description field.
496    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    /// Sums, per term, the best weight found across name/namespace/
519    /// tags/description. Returns `None` if any term matched nowhere
520    /// (preserving the previous AND-of-substrings filtering
521    /// behaviour), `Some(total_score)` otherwise.
522    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
550/// True if `term` appears in `field` as a whole word — surrounded by
551/// non-alphanumeric boundaries (or the string edges). Used to rank a
552/// standalone-word hit above a mid-word substring hit (e.g. "pdf" in
553/// tag "pdf" vs. tag "pdf-export") without requiring an exact match.
554fn 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        // Reported by a user whose `knack list` blew up on agent-browser's
611        // SKILL.md because it set `hidden: true`. Knack doesn't model that
612        // field — and shouldn't have to — so parsing must skip past it.
613        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        // Removing deny_unknown_fields shouldn't make typos in REQUIRED
634        // fields silent. Missing `description` should still fail.
635        assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
636    }
637
638    #[test]
639    fn validate_skill_metadata_ignores_directory_mismatch() {
640        // Vendors like Vercel and Remotion use unprefixed dirs with
641        // brand-prefixed frontmatter names — e.g.
642        // skills/composition-patterns/SKILL.md whose frontmatter
643        // declares `name: vercel-composition-patterns`. That's a
644        // legitimate convention and a registry indexing them must
645        // not reject these. validate_skill (strict) still does.
646        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        // Real ecosystem SKILL.md files (anthropics/skills/skill-creator
658        // is ~5200 chars) bury invocation guidance in description. The
659        // old 1024-char ceiling locked us out of indexing them. Empty
660        // descriptions still fail; length no longer does.
661        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        // Namespace itself is searchable so users can scope by vendor:
706        // `knack find anthropics` lists everything from that vendor.
707        assert_eq!(index.search("anthropics").len(), 1);
708    }
709
710    #[test]
711    fn ranks_name_matches_above_description_only_matches() {
712        // Both skills mention "rust" somewhere, but only one is
713        // actually named/tagged for it. The name/tag hit must
714        // outrank the incidental description mention so a query for
715        // "rust" doesn't bury the relevant skill under unrelated
716        // results that merely reference it in passing.
717        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        // AND semantics preserved: a skill matching only one of two
749        // terms must be excluded entirely, not merely ranked lower.
750        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        // Same kebab-case rules as skill name (URL-safe).
821        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        // Lockfiles written by knack 0.1.x had no `version` field and
839        // no `namespace`. Reading one with v2-aware knack must yield
840        // version=1, namespace=None — never silently promote to v2.
841        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        // index.json files produced by knack-registry 0.2.x don't
908        // carry a `namespace` field. They MUST keep deserializing —
909        // existing R2 buckets, lockfiles, and clients depend on that.
910        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        // Symmetric to the legacy-parse test: writing out an unscoped
926        // skill should not introduce a noisy `"namespace": null` into
927        // index.json. skip_serializing_if = "Option::is_none" enforces
928        // this round-trip cleanliness.
929        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}