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. Where a term matched affects
420    /// ranking (a hit in the name or tags counts for much more than
421    /// a hit buried in the description), and so does *how common*
422    /// the term is across the whole index: a generic word that shows
423    /// up in most skills' descriptions (e.g. "deploy") contributes
424    /// far less to the score than a term that's genuinely
425    /// distinctive, via a BM25-style inverse-document-frequency
426    /// weight computed per query. This keeps a broad, common query
427    /// term from single-handedly promoting every skill that happens
428    /// to mention it once in passing. Results are sorted
429    /// best-match-first (ties broken alphabetically by
430    /// `qualified_name()` for stable output).
431    pub fn search(&self, query: &str) -> Vec<(&IndexedSkill, f64)> {
432        let terms: Vec<String> = query
433            .split_whitespace()
434            .map(|term| term.to_ascii_lowercase())
435            .collect();
436        if terms.is_empty() {
437            return Vec::new();
438        }
439
440        let total_skills = self.skill.len();
441        // Document frequency per term: how many skills the term
442        // matches *somewhere*, independent of the other query terms.
443        // This is what lets a term's own weight reflect how
444        // discriminating it is across this specific index, rather
445        // than using a single fixed weight for every term regardless
446        // of how common it is.
447        let idf_weights: Vec<f64> = terms
448            .iter()
449            .map(|term| {
450                let doc_freq = self
451                    .skill
452                    .iter()
453                    .filter(|skill| skill.matches_term(term))
454                    .count();
455                inverse_document_frequency(total_skills, doc_freq)
456            })
457            .collect();
458
459        let mut scored: Vec<(&IndexedSkill, f64)> = self
460            .skill
461            .iter()
462            .filter_map(|skill| {
463                skill
464                    .match_score(&terms, &idf_weights)
465                    .map(|score| (skill, score))
466            })
467            .collect();
468
469        scored.sort_by(|(a, a_score), (b, b_score)| {
470            b_score
471                .partial_cmp(a_score)
472                .unwrap_or(std::cmp::Ordering::Equal)
473                .then_with(|| a.qualified_name().cmp(&b.qualified_name()))
474        });
475        scored
476    }
477}
478
479/// BM25-style smoothed inverse document frequency: `ln(1 + (N - df +
480/// 0.5) / (df + 0.5))`. A term present in only a handful of skills
481/// (small `df`) gets a large weight; a term present in most or all
482/// skills (large `df`, up to `N`) gets a small-but-positive weight —
483/// it never zeroes out entirely (a query term still had to match for
484/// the skill to be included at all via the AND filter in
485/// `match_score`), but it stops dominating the ranking the way a
486/// flat per-field weight would. `df` is expected to be at least 1
487/// here (only called for terms that matched something); `N == 0` or
488/// `df == 0` fall back to a neutral weight of `1.0` as a defensive
489/// guard rather than dividing by zero.
490fn inverse_document_frequency(total_skills: usize, doc_freq: usize) -> f64 {
491    if total_skills == 0 || doc_freq == 0 {
492        return 1.0;
493    }
494    let n = total_skills as f64;
495    let df = doc_freq as f64;
496    (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
497}
498
499impl IndexSource {
500    pub fn validate(&self) -> Result<()> {
501        if self.source.trim().is_empty() {
502            bail!("indexed source must not be empty");
503        }
504        Ok(())
505    }
506}
507
508impl IndexedSkill {
509    pub fn validate(&self) -> Result<()> {
510        validate_skill_name(&self.name)?;
511        if let Some(ns) = &self.namespace {
512            // Namespaces use the same character set as skill names —
513            // kebab-case for URL-safe round-tripping through
514            // `/skills/<ns>/<name>/archive`.
515            validate_skill_name(ns).map_err(|err| anyhow!("invalid namespace: {err}"))?;
516        }
517        if self.description.trim().is_empty() {
518            bail!("indexed skill description must not be empty: {}", self.name);
519        }
520        if self.source.trim().is_empty() {
521            bail!("indexed skill source must not be empty: {}", self.name);
522        }
523        Ok(())
524    }
525
526    /// `<namespace>/<name>` when scoped, bare `<name>` otherwise. This
527    /// is the on-the-wire identifier — what comes after `<registry>:`
528    /// in install commands, and what's used as the archive URL path
529    /// segment.
530    pub fn qualified_name(&self) -> String {
531        match &self.namespace {
532            Some(ns) => format!("{ns}/{}", self.name),
533            None => self.name.clone(),
534        }
535    }
536
537    /// Scores a single already-lowercased term against one field,
538    /// choosing the highest tier the term qualifies for in that
539    /// field. `is_name_or_tag` widens the "whole word" and "starts
540    /// with" tiers to also apply to tags/namespace, since those are
541    /// short identifier-like strings where a whole-word hit is just
542    /// as meaningful as an exact name match, unlike the free-form
543    /// description field.
544    fn field_weight(term: &str, field: &str, is_name_or_tag: bool) -> f64 {
545        if field.is_empty() {
546            return 0.0;
547        }
548        if is_name_or_tag {
549            if field == term {
550                return 4.0;
551            }
552            if field.starts_with(term) {
553                return 3.0;
554            }
555            if word_boundary_match(field, term) {
556                return 2.0;
557            }
558        }
559        if field.contains(term) {
560            if is_name_or_tag { 1.0 } else { 0.5 }
561        } else {
562            0.0
563        }
564    }
565
566    /// Best weight found for one already-lowercased `term` across
567    /// name/namespace/tags/description, or `0.0` if it matches
568    /// nowhere. Factored out of `match_score` so document-frequency
569    /// counting (`matches_term`) and scoring can share the same
570    /// per-field logic instead of drifting apart.
571    fn best_field_weight(&self, term: &str) -> f64 {
572        let name = self.name.to_ascii_lowercase();
573        let namespace = self
574            .namespace
575            .as_deref()
576            .map(|ns| ns.to_ascii_lowercase())
577            .unwrap_or_default();
578        let description = self.description.to_ascii_lowercase();
579
580        let mut best = Self::field_weight(term, &name, true);
581        best = best.max(Self::field_weight(term, &namespace, true));
582        for tag in &self.tags {
583            best = best.max(Self::field_weight(term, &tag.to_ascii_lowercase(), true));
584        }
585        best.max(Self::field_weight(term, &description, false))
586    }
587
588    /// Whether an already-lowercased `term` matches this skill
589    /// anywhere at all, independent of any other query terms. Used
590    /// to compute each term's document frequency across the whole
591    /// index for IDF weighting.
592    fn matches_term(&self, term: &str) -> bool {
593        self.best_field_weight(term) > 0.0
594    }
595
596    /// Sums, per term, the best field weight found across
597    /// name/namespace/tags/description, scaled by that term's IDF
598    /// weight (`idf_weights[i]`, aligned by position with `terms`)
599    /// so common terms contribute less than distinctive ones.
600    /// Returns `None` if any term matched nowhere (preserving the
601    /// AND-of-substrings filtering behaviour), `Some(total_score)`
602    /// otherwise.
603    fn match_score(&self, terms: &[String], idf_weights: &[f64]) -> Option<f64> {
604        let mut total = 0.0;
605        for (term, idf) in terms.iter().zip(idf_weights) {
606            let best = self.best_field_weight(term);
607            if best <= 0.0 {
608                return None;
609            }
610            total += best * idf;
611        }
612        Some(total)
613    }
614}
615
616/// True if `term` appears in `field` as a whole word — surrounded by
617/// non-alphanumeric boundaries (or the string edges). Used to rank a
618/// standalone-word hit above a mid-word substring hit (e.g. "pdf" in
619/// tag "pdf" vs. tag "pdf-export") without requiring an exact match.
620fn word_boundary_match(field: &str, term: &str) -> bool {
621    let mut start = 0;
622    while let Some(idx) = field[start..].find(term) {
623        let match_start = start + idx;
624        let match_end = match_start + term.len();
625        let before_ok = match_start == 0
626            || !field[..match_start]
627                .chars()
628                .next_back()
629                .is_some_and(|c| c.is_alphanumeric());
630        let after_ok = match_end == field.len()
631            || !field[match_end..]
632                .chars()
633                .next()
634                .is_some_and(|c| c.is_alphanumeric());
635        if before_ok && after_ok {
636            return true;
637        }
638        start = match_start + 1;
639        if start >= field.len() {
640            break;
641        }
642    }
643    false
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn validates_skill_names() {
652        assert!(validate_skill_name("rust-code-review").is_ok());
653        assert!(validate_skill_name("Rust-Code-Review").is_err());
654        assert!(validate_skill_name("-rust").is_err());
655        assert!(validate_skill_name("rust-").is_err());
656        assert!(validate_skill_name("rust--review").is_err());
657    }
658
659    #[test]
660    fn parses_frontmatter() {
661        let frontmatter =
662            parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
663                .expect("frontmatter should parse");
664
665        assert_eq!(frontmatter.name, "demo-skill");
666        assert_eq!(frontmatter.description, "Use for demos.");
667    }
668
669    #[test]
670    fn rejects_missing_frontmatter() {
671        assert!(parse_frontmatter("# demo\n").is_err());
672    }
673
674    #[test]
675    fn tolerates_unknown_frontmatter_fields() {
676        // Reported by a user whose `knack list` blew up on agent-browser's
677        // SKILL.md because it set `hidden: true`. Knack doesn't model that
678        // field — and shouldn't have to — so parsing must skip past it.
679        let frontmatter = parse_frontmatter(
680            "---\n\
681             name: agent-browser\n\
682             description: Browser automation.\n\
683             allowed-tools: Bash(agent-browser:*)\n\
684             hidden: true\n\
685             custom-field: arbitrary\n\
686             ---\n",
687        )
688        .expect("foreign fields must be ignored, not rejected");
689        assert_eq!(frontmatter.name, "agent-browser");
690        assert_eq!(frontmatter.description, "Browser automation.");
691        assert_eq!(
692            frontmatter.allowed_tools.as_deref(),
693            Some("Bash(agent-browser:*)")
694        );
695    }
696
697    #[test]
698    fn still_requires_name_and_description() {
699        // Removing deny_unknown_fields shouldn't make typos in REQUIRED
700        // fields silent. Missing `description` should still fail.
701        assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
702    }
703
704    #[test]
705    fn validate_skill_metadata_ignores_directory_mismatch() {
706        // Vendors like Vercel and Remotion use unprefixed dirs with
707        // brand-prefixed frontmatter names — e.g.
708        // skills/composition-patterns/SKILL.md whose frontmatter
709        // declares `name: vercel-composition-patterns`. That's a
710        // legitimate convention and a registry indexing them must
711        // not reject these. validate_skill (strict) still does.
712        let skill = Skill {
713            path: PathBuf::from("/tmp/composition-patterns"),
714            name: "vercel-composition-patterns".to_string(),
715            description: "React composition patterns.".to_string(),
716        };
717        assert!(validate_skill_metadata(&skill).is_ok());
718        assert!(validate_skill(&skill).is_err());
719    }
720
721    #[test]
722    fn accepts_long_descriptions() {
723        // Real ecosystem SKILL.md files (anthropics/skills/skill-creator
724        // is ~5200 chars) bury invocation guidance in description. The
725        // old 1024-char ceiling locked us out of indexing them. Empty
726        // descriptions still fail; length no longer does.
727        let long = "Use when the user is doing things. ".repeat(200);
728        assert!(long.len() > 1024);
729        let skill = Skill {
730            path: PathBuf::from("/tmp/example"),
731            name: "example".to_string(),
732            description: long,
733        };
734        assert!(validate_skill(&skill).is_ok());
735
736        let blank = Skill {
737            path: PathBuf::from("/tmp/example"),
738            name: "example".to_string(),
739            description: "   ".to_string(),
740        };
741        assert!(validate_skill(&blank).is_err());
742    }
743
744    #[test]
745    fn searches_registry_index() {
746        let index = RegistryIndex {
747            skill: vec![
748                IndexedSkill {
749                    name: "pdf".to_string(),
750                    namespace: Some("anthropics".to_string()),
751                    description: "Work with PDF documents".to_string(),
752                    source: "anthropics/pdf".to_string(),
753                    tags: vec!["documents".to_string(), "ocr".to_string()],
754                    score: None,
755                },
756                IndexedSkill {
757                    name: "rust-code-review".to_string(),
758                    namespace: None,
759                    description: "Review Rust code".to_string(),
760                    source: "rust-code-review".to_string(),
761                    tags: vec!["rust".to_string()],
762                    score: None,
763                },
764            ],
765            source: Vec::new(),
766        };
767
768        assert_eq!(index.search("pdf").len(), 1);
769        assert_eq!(index.search("documents ocr").len(), 1);
770        assert_eq!(index.search("python").len(), 0);
771        // Namespace itself is searchable so users can scope by vendor:
772        // `knack find anthropics` lists everything from that vendor.
773        assert_eq!(index.search("anthropics").len(), 1);
774    }
775
776    #[test]
777    fn ranks_name_matches_above_description_only_matches() {
778        // Both skills mention "rust" somewhere, but only one is
779        // actually named/tagged for it. The name/tag hit must
780        // outrank the incidental description mention so a query for
781        // "rust" doesn't bury the relevant skill under unrelated
782        // results that merely reference it in passing.
783        let index = RegistryIndex {
784            skill: vec![
785                IndexedSkill {
786                    name: "changelog-writer".to_string(),
787                    namespace: None,
788                    description: "Summarize commits, including ones touching Rust code."
789                        .to_string(),
790                    source: "changelog-writer".to_string(),
791                    tags: vec![],
792                    score: None,
793                },
794                IndexedSkill {
795                    name: "rust-code-review".to_string(),
796                    namespace: None,
797                    description: "Review code for correctness".to_string(),
798                    source: "rust-code-review".to_string(),
799                    tags: vec!["rust".to_string()],
800                    score: None,
801                },
802            ],
803            source: Vec::new(),
804        };
805
806        let results = index.search("rust");
807        assert_eq!(results.len(), 2);
808        assert_eq!(results[0].0.name, "rust-code-review");
809        assert!(results[0].1 > results[1].1);
810    }
811
812    #[test]
813    fn discounts_common_terms_against_rare_terms_via_idf() {
814        // Two-term query "ci deploy" where "ci" is rare across the
815        // index but "deploy" is common (mentioned in passing by many
816        // unrelated skills' descriptions). Skill `x` matches the
817        // *rare* term strongly (exact tag) and the common term only
818        // weakly (description substring); skill `y` is the mirror
819        // image — strong on the *common* term, weak on the rare one.
820        // Their flat, un-weighted field scores are equal (4.0 + 0.5
821        // each way), so without IDF weighting they'd tie. With IDF,
822        // `x`'s strong hit on the rarer, more discriminating term
823        // should outrank `y`'s strong hit on the term that's common
824        // enough to appear almost everywhere.
825        let mut skill = vec![
826            IndexedSkill {
827                name: "ci-tools".to_string(),
828                namespace: None,
829                description: "Helps deploy your pipeline safely.".to_string(),
830                source: "ci-tools".to_string(),
831                tags: vec!["ci".to_string()],
832                score: None,
833            },
834            IndexedSkill {
835                name: "deploy".to_string(),
836                namespace: None,
837                description: "Also handles some ci related tasks.".to_string(),
838                source: "deploy".to_string(),
839                tags: vec!["deploy".to_string()],
840                score: None,
841            },
842        ];
843        // Filler skills that mention "deploy" in passing (inflating
844        // its document frequency) but never "ci", so "ci" stays rare
845        // relative to "deploy" across the whole index.
846        for i in 0..15 {
847            skill.push(IndexedSkill {
848                name: format!("filler-{i}"),
849                namespace: None,
850                description: "Handles deployment automation for unrelated workflows.".to_string(),
851                source: format!("filler-{i}"),
852                tags: vec![],
853                score: None,
854            });
855        }
856        let index = RegistryIndex {
857            skill,
858            source: Vec::new(),
859        };
860
861        let results = index.search("ci deploy");
862        assert_eq!(results.len(), 2);
863        assert_eq!(
864            results[0].0.name, "ci-tools",
865            "strong match on the rarer, more discriminating term should outrank \
866             a strong match on the term that's common across the index"
867        );
868        assert!(results[0].1 > results[1].1);
869    }
870
871    #[test]
872    fn requires_every_term_to_match_somewhere() {
873        // AND semantics preserved: a skill matching only one of two
874        // terms must be excluded entirely, not merely ranked lower.
875        let index = RegistryIndex {
876            skill: vec![IndexedSkill {
877                name: "pdf".to_string(),
878                namespace: Some("anthropics".to_string()),
879                description: "Work with PDF documents".to_string(),
880                source: "anthropics/pdf".to_string(),
881                tags: vec!["documents".to_string()],
882                score: None,
883            }],
884            source: Vec::new(),
885        };
886
887        assert_eq!(index.search("pdf python").len(), 0);
888        assert_eq!(index.search("pdf documents").len(), 1);
889    }
890
891    #[test]
892    fn ties_break_alphabetically_by_qualified_name() {
893        let index = RegistryIndex {
894            skill: vec![
895                IndexedSkill {
896                    name: "zeta".to_string(),
897                    namespace: None,
898                    description: "docs helper".to_string(),
899                    source: "zeta".to_string(),
900                    tags: vec![],
901                    score: None,
902                },
903                IndexedSkill {
904                    name: "alpha".to_string(),
905                    namespace: None,
906                    description: "docs helper".to_string(),
907                    source: "alpha".to_string(),
908                    tags: vec![],
909                    score: None,
910                },
911            ],
912            source: Vec::new(),
913        };
914
915        let results = index.search("docs");
916        assert_eq!(results[0].0.name, "alpha");
917        assert_eq!(results[1].0.name, "zeta");
918    }
919
920    #[test]
921    fn qualified_name_round_trips() {
922        let scoped = IndexedSkill {
923            name: "pdf".to_string(),
924            namespace: Some("anthropics".to_string()),
925            description: "x".to_string(),
926            source: "anthropics/pdf".to_string(),
927            tags: vec![],
928            score: None,
929        };
930        assert_eq!(scoped.qualified_name(), "anthropics/pdf");
931
932        let unscoped = IndexedSkill {
933            name: "legacy".to_string(),
934            namespace: None,
935            description: "x".to_string(),
936            source: "legacy".to_string(),
937            tags: vec![],
938            score: None,
939        };
940        assert_eq!(unscoped.qualified_name(), "legacy");
941    }
942
943    #[test]
944    fn validates_namespace_charset() {
945        // Same kebab-case rules as skill name (URL-safe).
946        let mut skill = IndexedSkill {
947            name: "ok".to_string(),
948            namespace: Some("good-ns".to_string()),
949            description: "x".to_string(),
950            source: "good-ns/ok".to_string(),
951            tags: vec![],
952            score: None,
953        };
954        assert!(skill.validate().is_ok());
955
956        skill.namespace = Some("Bad_Namespace".to_string());
957        let err = skill.validate().unwrap_err().to_string();
958        assert!(err.contains("invalid namespace"), "got: {err}");
959    }
960
961    #[test]
962    fn parses_v1_lockfile_without_version_or_namespace() {
963        // Lockfiles written by knack 0.1.x had no `version` field and
964        // no `namespace`. Reading one with v2-aware knack must yield
965        // version=1, namespace=None — never silently promote to v2.
966        let toml_v1 = r#"
967[[skill]]
968name = "pdf"
969source = "public:pdf"
970resolved = "http+knack:https://example.com/skills/pdf/archive#sha=abc123"
971checksum = "sha256:deadbeef"
972"#;
973        let lockfile: Lockfile = toml::from_str(toml_v1).expect("v1 lockfile must parse");
974        assert_eq!(lockfile.version, 1);
975        assert_eq!(lockfile.skill.len(), 1);
976        assert_eq!(lockfile.skill[0].namespace, None);
977        assert!(lockfile.ensure_supported_version().is_ok());
978    }
979
980    #[test]
981    fn parses_v2_lockfile_with_namespace() {
982        let toml_v2 = r#"
983version = 2
984
985[[skill]]
986name = "pdf"
987namespace = "anthropics"
988source = "public:anthropics/pdf"
989resolved = "http+knack:https://example.com/skills/anthropics/pdf/archive#sha=abc"
990checksum = "sha256:deadbeef"
991"#;
992        let lockfile: Lockfile = toml::from_str(toml_v2).expect("v2 lockfile must parse");
993        assert_eq!(lockfile.version, 2);
994        assert_eq!(lockfile.skill[0].namespace.as_deref(), Some("anthropics"));
995    }
996
997    #[test]
998    fn rejects_lockfile_from_newer_knack() {
999        let future = r#"
1000version = 999
1001[[skill]]
1002name = "pdf"
1003source = "public:pdf"
1004resolved = "x"
1005checksum = "x"
1006"#;
1007        let lockfile: Lockfile = toml::from_str(future).unwrap();
1008        let err = lockfile
1009            .ensure_supported_version()
1010            .expect_err("future lockfile must be rejected");
1011        assert!(err.contains("newer than this knack supports"), "got: {err}");
1012    }
1013
1014    #[test]
1015    fn locked_skill_omits_namespace_when_absent() {
1016        let skill = LockedSkill {
1017            name: "pdf".to_string(),
1018            namespace: None,
1019            source: "public:pdf".to_string(),
1020            resolved: "x".to_string(),
1021            checksum: "y".to_string(),
1022        };
1023        let serialized = toml::to_string(&skill).unwrap();
1024        assert!(
1025            !serialized.contains("namespace"),
1026            "namespace should be omitted from legacy entries, got: {serialized}"
1027        );
1028    }
1029
1030    #[test]
1031    fn parses_legacy_unnamespaced_index_json() {
1032        // index.json files produced by knack-registry 0.2.x don't
1033        // carry a `namespace` field. They MUST keep deserializing —
1034        // existing R2 buckets, lockfiles, and clients depend on that.
1035        let json = r#"{
1036            "name": "pdf",
1037            "description": "PDF docs",
1038            "source": "public:pdf",
1039            "tags": ["documents"]
1040        }"#;
1041        let parsed: IndexedSkill =
1042            serde_json::from_str(json).expect("legacy index.json must parse");
1043        assert_eq!(parsed.name, "pdf");
1044        assert_eq!(parsed.namespace, None);
1045        assert_eq!(parsed.qualified_name(), "pdf");
1046    }
1047
1048    #[test]
1049    fn omits_namespace_field_when_absent_on_serialize() {
1050        // Symmetric to the legacy-parse test: writing out an unscoped
1051        // skill should not introduce a noisy `"namespace": null` into
1052        // index.json. skip_serializing_if = "Option::is_none" enforces
1053        // this round-trip cleanliness.
1054        let skill = IndexedSkill {
1055            name: "legacy".to_string(),
1056            namespace: None,
1057            description: "x".to_string(),
1058            source: "legacy".to_string(),
1059            tags: vec![],
1060            score: None,
1061        };
1062        let json = serde_json::to_string(&skill).unwrap();
1063        assert!(
1064            !json.contains("namespace"),
1065            "namespace should be omitted, got: {json}"
1066        );
1067    }
1068}