@soulcraft/kit-schema 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/skills.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @module @soulcraft/kit-schema/skills
3
+ * @description SKILL.md frontmatter parser for Soulcraft kit AI skills.
4
+ *
5
+ * Skills are AI capability definitions stored in `kits/{id}/skills/{name}/SKILL.md`.
6
+ * Each file has a YAML frontmatter block (between `---` markers) followed by
7
+ * Markdown content that forms the skill's instruction body.
8
+ *
9
+ * This module parses both sections and returns a typed {@link ParsedSkill} object
10
+ * suitable for inclusion in AI system prompts.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { parseSkillFile } from '@soulcraft/kit-schema/skills';
15
+ * import { readFile } from 'node:fs/promises';
16
+ *
17
+ * const raw = await readFile('kits/wicks-and-whiskers/skills/memory-narrative/SKILL.md', 'utf-8');
18
+ * const skill = parseSkillFile(raw, '/path/to/SKILL.md');
19
+ * // skill.frontmatter.type === 'invocable'
20
+ * // skill.body === '## Candle Memory Narrative\n...'
21
+ * ```
22
+ */
23
+
24
+ import type { SkillFrontmatter, ParsedSkill } from './types.js';
25
+
26
+ /**
27
+ * Parse a SKILL.md file's content into a {@link ParsedSkill} object.
28
+ *
29
+ * The file must begin with a YAML frontmatter block delimited by `---`.
30
+ * Required frontmatter fields: `id`, `name`, `description`, `type`.
31
+ *
32
+ * @param content - Full text content of the SKILL.md file.
33
+ * @param filePath - Absolute filesystem path to the SKILL.md file (used for error messages).
34
+ * @returns Parsed skill with validated frontmatter and body content.
35
+ * @throws {Error} If the frontmatter block is missing, malformed, or missing required fields.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const skill = parseSkillFile(fileContent, '/kits/wicks/skills/memory-narrative/SKILL.md');
40
+ * console.log(skill.frontmatter.type); // "invocable"
41
+ * ```
42
+ */
43
+ export function parseSkillFile(content: string, filePath: string): ParsedSkill {
44
+ if (!content.startsWith('---')) {
45
+ throw new Error(`SKILL.md at ${filePath} must begin with a YAML frontmatter block (---)`);
46
+ }
47
+
48
+ const endMarker = content.indexOf('\n---', 3);
49
+ if (endMarker === -1) {
50
+ throw new Error(`SKILL.md at ${filePath} has an unclosed frontmatter block (missing closing ---)`);
51
+ }
52
+
53
+ const frontmatterText = content.slice(3, endMarker).trim();
54
+ const body = content.slice(endMarker + 4).trim();
55
+
56
+ const frontmatter = parseYamlFrontmatter(frontmatterText, filePath);
57
+
58
+ return { filePath, frontmatter, body };
59
+ }
60
+
61
+ /**
62
+ * Minimal YAML parser for SKILL.md frontmatter.
63
+ *
64
+ * Supports the simple key: value format used in skill definitions.
65
+ * Does not support nested objects, arrays, or multi-line values —
66
+ * skill frontmatter intentionally uses only flat string/boolean fields.
67
+ *
68
+ * @param text - The raw YAML text between the `---` delimiters.
69
+ * @param filePath - File path for error messages.
70
+ * @returns Validated {@link SkillFrontmatter} object.
71
+ * @throws {Error} If required fields are missing or `type` has an invalid value.
72
+ */
73
+ function parseYamlFrontmatter(text: string, filePath: string): SkillFrontmatter {
74
+ const record: Record<string, string | boolean> = {};
75
+
76
+ for (const line of text.split('\n')) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed || trimmed.startsWith('#')) continue;
79
+
80
+ const colonIdx = trimmed.indexOf(':');
81
+ if (colonIdx === -1) continue;
82
+
83
+ const key = trimmed.slice(0, colonIdx).trim();
84
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
85
+
86
+ // Unquote string values
87
+ const value = rawValue.startsWith('"') && rawValue.endsWith('"')
88
+ ? rawValue.slice(1, -1)
89
+ : rawValue.startsWith("'") && rawValue.endsWith("'")
90
+ ? rawValue.slice(1, -1)
91
+ : rawValue === 'true'
92
+ ? true
93
+ : rawValue === 'false'
94
+ ? false
95
+ : rawValue;
96
+
97
+ record[key] = value;
98
+ }
99
+
100
+ // Validate required fields
101
+ const { id, name, description, type, version, requiresVision, requiresData } = record;
102
+
103
+ if (!id || typeof id !== 'string') {
104
+ throw new Error(`SKILL.md at ${filePath} is missing required frontmatter field: 'id'`);
105
+ }
106
+ if (!name || typeof name !== 'string') {
107
+ throw new Error(`SKILL.md at ${filePath} is missing required frontmatter field: 'name'`);
108
+ }
109
+ if (!description || typeof description !== 'string') {
110
+ throw new Error(`SKILL.md at ${filePath} is missing required frontmatter field: 'description'`);
111
+ }
112
+ if (type !== 'background' && type !== 'invocable') {
113
+ throw new Error(
114
+ `SKILL.md at ${filePath} has invalid 'type': "${String(type)}". Must be "background" or "invocable".`
115
+ );
116
+ }
117
+
118
+ return {
119
+ id,
120
+ name,
121
+ description,
122
+ type,
123
+ version: typeof version === 'string' ? version : undefined,
124
+ requiresVision: typeof requiresVision === 'boolean' ? requiresVision : undefined,
125
+ requiresData: typeof requiresData === 'boolean' ? requiresData : undefined
126
+ };
127
+ }