Skip to main content

craftbag/
lib.rs

1//! Public types, SKILL.md parse, discovery, and activation selector.
2//!
3//! [`DiscoveryOptions::default`] sets `implicit_roots: true` so
4//! [`discover`] walks cwd-to-git `.agents` / vendor trees and
5//! `$HOME/.agents` / vendor trees.
6//! CLI `--no-implicit-roots` and MCP `implicit_roots: false` turn that
7//! walk off; extra `paths` and `user_skills_dir` still load.
8//!
9//! [`SkillMiss`] peels `error_kind`, `error`, and `path` so a leftover-only
10//! host can branch without scraping Display. `unknown_skill` omits `path`.
11//! A `name_collision` skip also peels `winner_path`. Other misses omit it.
12//!
13//! List JSON, why JSON, and list XML share [`SkillSummary`]
14//! (`description`, invocation flags, `argument_hint`, `when_to_use`,
15//! `triggers`, `allowed_tools`, `license`, `compatibility`, `metadata`).
16//! A new field on that type must land in all three wires
17//! (`skill_summary_json_keys_have_list_xml_siblings`). Catalog stays cheap
18//! and omits `disable_model_invocation` (official client-guide). JSON, XML,
19//! and TSV still list those rows.
20//! [`format_load_message`] is the text envelope (`License`,
21//! `Compatibility`, `Metadata`, `Allowed tools`, and host extras
22//! when set). [`format_load_view`] can print an outline or one
23//! heading section of that same SKILL.md body instead of the
24//! whole body. It does not dump `scripts/` or `references/`.
25//!
26//! [`validate_path_with_options`] accepts a SKILL.md file or package directory
27//! (joins `SKILL.md` / `skill.md`). Success is [`ValidationReport`]
28//! (no `error_kind`). A miss is [`ValidationReport::miss`]. CLI
29//! `validate --json` and MCP `skills_validate` share that report.
30//!
31//! [`format_skip_tsv`] is the skip TSV source (`skip\tkind\tpath\tdetail`)
32//! for CLI list stderr, CLI why stdout, and MCP catalog/xml text.
33//! [`format_list_tsv`] is default list TSV. [`format_why_text`] is CLI
34//! why text and MCP `skills_why` format=text (loaded rows, skip TSV,
35//! activation). [`format_watch_dirs`] is CLI `list --watch-dirs` and
36//! MCP `skills_list format=watch`. Do not inline those rows on a new
37//! text surface.
38
39mod activate;
40mod discover;
41mod error;
42mod miss;
43mod parse;
44mod sections;
45mod skill;
46mod skip;
47mod source;
48mod why;
49
50pub use activate::{
51    DEFAULT_ACTIVATE_HINT, FormatOptions, ListFormat, LoadView, ProgressiveBudgets, filter_skills,
52    format_available_skills_xml, format_catalog, format_load_message, format_load_view,
53    format_package_envelope, parse_list_format, progressive_budgets, rank_skills_for_catalog,
54    skill_relevance_score, trigger_matches, unknown_list_format,
55};
56pub use discover::{
57    CURSOR_VENDOR_DENYLIST, DiscoveryOptions, ValidationReport, discover, find_skill_by_name,
58    format_watch_dirs, validate_path, validate_path_with_options, walk_cwd_to_git_root, watch_dirs,
59    with_home_override,
60};
61pub use error::{Error, ParseError, sanitize_error_token};
62pub use miss::{
63    SkillMiss, UNKNOWN_SKILL_KIND, unknown_or_skipped_skill, unknown_or_skipped_skill_message,
64    unknown_or_skipped_skill_named,
65};
66pub use parse::{
67    normalize_skill_name, parse_skill, skill_name_is_ascii_policy, skill_name_matches_directory,
68    skill_names_equal, validate_skill_name,
69};
70pub use sections::{
71    CHARS_PER_TOKEN, SkillOutline, SkillSection, SkillSectionMeta, WHOLE_BODY_CHEAPER_TOKENS,
72    estimate_tokens, outline_of, skill_section, split_sections, unknown_section_message,
73};
74pub use skill::{
75    SKILL_BODY_LINE_SOFT_WARN, SKILL_COMPATIBILITY_MAX_CHARS, SKILL_DESCRIPTION_MAX_CHARS,
76    SKILL_MD_MAX_BYTES, SKILL_NAME_MAX_CHARS, Skill,
77};
78pub use skip::{
79    DiscoveryReport, HostTokenField, SkillSkip, SkipKind, format_list_tsv, format_skip_tsv,
80};
81pub use source::SkillSource;
82pub use why::{
83    ActivationDecision, ActivationReason, SkillSummary, WhyReport, format_why_text, why,
84};
85
86/// Package version from `Cargo.toml`.
87pub fn version() -> &'static str {
88    env!("CARGO_PKG_VERSION")
89}
90
91#[cfg(test)]
92mod tests {
93    #[test]
94    fn version_is_nonzero() {
95        assert!(!super::version().is_empty());
96    }
97
98    /// The walk is cwd-to-git `.agents` / `$HOME/.agents`, not the whole
99    /// cwd-to-git tree. That inverted sentence drifted once (PRs 170-172).
100    #[test]
101    fn crate_root_docs_attach_agents_to_cwd_to_git() {
102        let docs: String = include_str!("lib.rs")
103            .lines()
104            .filter(|line| line.starts_with("//!"))
105            .collect::<Vec<_>>()
106            .join("\n");
107        assert!(
108            docs.contains("cwd-to-git `.agents`"),
109            "crate-root rustdoc must attach .agents to cwd-to-git, not walk the whole tree: {docs}"
110        );
111        assert!(
112            super::DiscoveryOptions::default().implicit_roots,
113            "documented default must stay true"
114        );
115    }
116
117    #[test]
118    fn unknown_skill_miss_omits_path() {
119        let unknown = super::unknown_or_skipped_skill("no-such-skill", &[]);
120        assert_eq!(unknown.error_kind, super::UNKNOWN_SKILL_KIND);
121        assert!(unknown.path.is_none(), "unknown_skill miss must omit path");
122        let json = serde_json::to_value(&unknown).expect("miss serde");
123        assert!(
124            json.get("path").is_none(),
125            "unknown_skill JSON omits path: {json}"
126        );
127        assert!(
128            json.get("winner_path").is_none(),
129            "unknown_skill JSON omits winner_path: {json}"
130        );
131    }
132
133    #[test]
134    fn name_collision_miss_peels_winner_path() {
135        use std::path::PathBuf;
136
137        use super::{SkillSkip, SkipKind, unknown_or_skipped_skill};
138
139        let skip = SkillSkip {
140            path: PathBuf::from("/tmp/b/foo/SKILL.md"),
141            name: Some("foo".to_owned()),
142            kind: SkipKind::NameCollision,
143            detail: "lost to /tmp/a/foo/SKILL.md".to_owned(),
144            winner_path: Some(PathBuf::from("/tmp/a/foo/SKILL.md")),
145            ..SkillSkip::default()
146        };
147        let miss = unknown_or_skipped_skill("foo", std::slice::from_ref(&skip));
148        assert_eq!(miss.error_kind, "name_collision");
149        assert_eq!(miss.path.as_deref(), Some(skip.path.as_path()));
150        assert_eq!(
151            miss.winner_path.as_deref(),
152            Some(std::path::Path::new("/tmp/a/foo/SKILL.md"))
153        );
154        let json = serde_json::to_value(&miss).expect("miss serde");
155        assert_eq!(json["error_kind"], "name_collision");
156        assert_eq!(json["winner_path"], "/tmp/a/foo/SKILL.md");
157    }
158
159    #[test]
160    fn validation_report_success_has_no_error_kind() {
161        let dir = tempfile::tempdir().expect("pkg");
162        let pkg = dir.path().join("demo");
163        std::fs::create_dir_all(&pkg).expect("dir");
164        std::fs::write(
165            pkg.join("SKILL.md"),
166            "---\nname: demo\ndescription: ok\n---\nbody\n",
167        )
168        .expect("skill");
169        let report = super::validate_path(&pkg);
170        assert!(report.ok, "package directory must validate: {report:?}");
171        assert!(report.miss().is_none(), "ok report has no miss");
172        let json = serde_json::to_value(&report).expect("report serde");
173        assert!(
174            json.get("error_kind").is_none(),
175            "success ValidationReport must not grow error_kind: {json}"
176        );
177    }
178}