@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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@soulcraft/kit-schema",
3
+ "version": "2.0.0",
4
+ "description": "Shared kit manifest schema for Soulcraft platform kits (Venue, Workshop, and future products)",
5
+ "license": "UNLICENSED",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./*": "./src/*.ts"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "vitest run"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^25.3.0",
20
+ "typescript": "^5.7.3",
21
+ "vitest": "^3.0.5"
22
+ },
23
+ "dependencies": {
24
+ "@soulcraft/theme": "^1.0.0",
25
+ "zod": "^4.0.0"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @module @soulcraft/kit-schema
3
+ * @description Shared kit manifest schema for the Soulcraft platform ecosystem.
4
+ *
5
+ * This package defines the contract between Soulcraft platform products (Venue,
6
+ * Workshop) and the kits that configure them for specific verticals or use cases.
7
+ *
8
+ * ## What is a Kit?
9
+ *
10
+ * A kit is a directory containing:
11
+ * - `kit.json` — The manifest: metadata, feature flags, theme, experience types
12
+ * - `files/` — Template files (CMS content, email templates, waiver text)
13
+ * - `skills/` — AI skill definitions in SKILL.md format
14
+ *
15
+ * ## Key Exports
16
+ *
17
+ * - **Types** — `VenueKitConfig`, `KitManifest`, `VenueFeatures`, etc.
18
+ * - **Schemas** — Zod schemas for validating `kit.json` files
19
+ * - **Loader** — `loadKitFromDirectory()`, `loadVenueKit()`
20
+ * - **Skills** — `parseSkillFile()` for SKILL.md frontmatter parsing
21
+ * - **Variables** — `substituteVariables()` for `{{key}}` template substitution
22
+ *
23
+ * ## Usage
24
+ *
25
+ * ```ts
26
+ * import { loadVenueKit } from '@soulcraft/kit-schema';
27
+ *
28
+ * const kit = await loadVenueKit('./kits/wicks-and-whiskers');
29
+ * const { features, theme, experienceTypes } = kit.manifest.venue;
30
+ * ```
31
+ */
32
+
33
+ export * from './types.js';
34
+ export * from './schema.js';
35
+ export * from './loader.js';
36
+ export * from './skills.js';
37
+ export * from './variables.js';
package/src/loader.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @module @soulcraft/kit-schema/loader
3
+ * @description Kit manifest loader and directory validator for Soulcraft platform kits.
4
+ *
5
+ * This module provides the reference implementation for loading and validating kits
6
+ * from the filesystem. Platform applications (Venue, Workshop) use this to mount
7
+ * kits at startup.
8
+ *
9
+ * The loader:
10
+ * 1. Reads `kit.json` from the kit directory
11
+ * 2. Validates it against the Zod schema
12
+ * 3. Discovers and parses all SKILL.md files in `skills/`
13
+ * 4. Returns a fully typed {@link LoadedKit} with manifest + skills
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { loadKitFromDirectory } from '@soulcraft/kit-schema/loader';
18
+ *
19
+ * const kit = await loadKitFromDirectory('/app/kits/wicks-and-whiskers');
20
+ * console.log(kit.manifest.name); // 'Wicks & Whiskers'
21
+ * console.log(kit.skills.length); // 4
22
+ * ```
23
+ */
24
+
25
+ import { readFile, readdir, stat } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ import { KitManifestSchema } from './schema.js';
28
+ import { parseSkillFile } from './skills.js';
29
+ import type { KitManifest, ParsedSkill, SoulcraftKitConfig, VenueKitConfig } from './types.js';
30
+
31
+ /**
32
+ * A fully loaded and validated kit, ready for use by the platform.
33
+ */
34
+ export interface LoadedKit {
35
+ /** Validated and typed kit manifest from `kit.json`. */
36
+ manifest: KitManifest;
37
+ /** All SKILL.md files discovered and parsed from `skills/`. */
38
+ skills: ParsedSkill[];
39
+ /** Resolved absolute path to the kit directory root. */
40
+ kitDir: string;
41
+ }
42
+
43
+ /**
44
+ * A fully loaded Venue kit with the manifest narrowed to {@link VenueKitConfig}.
45
+ */
46
+ export interface LoadedVenueKit extends LoadedKit {
47
+ manifest: VenueKitConfig;
48
+ }
49
+
50
+ /**
51
+ * A fully loaded unified Soulcraft kit with the manifest narrowed to {@link SoulcraftKitConfig}.
52
+ */
53
+ export interface LoadedSoulcraftKit extends LoadedKit {
54
+ manifest: SoulcraftKitConfig;
55
+ }
56
+
57
+ /**
58
+ * Load and validate a kit from its directory on the filesystem.
59
+ *
60
+ * Reads `kit.json`, validates it with the Zod schema, then discovers all
61
+ * `skills/{name}/SKILL.md` files and parses their frontmatter.
62
+ *
63
+ * @param kitDir - Absolute path to the kit directory (must contain `kit.json`).
64
+ * @returns A {@link LoadedKit} with validated manifest and parsed skills.
65
+ * @throws {Error} If `kit.json` is missing, invalid JSON, or fails schema validation.
66
+ * @throws {Error} If any SKILL.md file has malformed frontmatter.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const kit = await loadKitFromDirectory('/app/kits/wicks-and-whiskers');
71
+ * ```
72
+ */
73
+ export async function loadKitFromDirectory(kitDir: string): Promise<LoadedKit> {
74
+ const manifestPath = join(kitDir, 'kit.json');
75
+
76
+ let rawManifest: unknown;
77
+ try {
78
+ const content = await readFile(manifestPath, 'utf-8');
79
+ rawManifest = JSON.parse(content);
80
+ } catch (err) {
81
+ const message = err instanceof SyntaxError
82
+ ? `kit.json at ${manifestPath} is not valid JSON: ${err.message}`
83
+ : `Could not read kit.json at ${manifestPath}: ${String(err)}`;
84
+ throw new Error(message);
85
+ }
86
+
87
+ // Strip the JSON Schema $schema key — it's an editor hint, not part of the kit spec.
88
+ if (rawManifest !== null && typeof rawManifest === 'object' && '$schema' in rawManifest) {
89
+ const { $schema: _, ...rest } = rawManifest as Record<string, unknown>;
90
+ rawManifest = rest;
91
+ }
92
+
93
+ const result = KitManifestSchema.safeParse(rawManifest);
94
+ if (!result.success) {
95
+ const formatted = result.error.issues
96
+ .map((issue) => ` ${issue.path.join('.')}: ${issue.message}`)
97
+ .join('\n');
98
+ throw new Error(`Invalid kit manifest at ${manifestPath}:\n${formatted}`);
99
+ }
100
+
101
+ const skills = await loadSkillsFromDirectory(kitDir);
102
+
103
+ return {
104
+ manifest: result.data,
105
+ skills,
106
+ kitDir
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Load and validate a Venue kit, narrowing the manifest type to {@link VenueKitConfig}.
112
+ *
113
+ * Also accepts `type: 'soulcraft'` kits that declare a `venue` config block —
114
+ * these are valid Venue kits in the unified kit system. The manifest is returned
115
+ * as-is (typed as `VenueKitConfig` for backward compatibility), so callers must
116
+ * check `manifest.type === 'soulcraft'` and access `manifest.venue` directly when
117
+ * they need the venue config.
118
+ *
119
+ * @param kitDir - Absolute path to the Venue kit directory.
120
+ * @returns A {@link LoadedVenueKit} with a narrowed manifest type, or a
121
+ * {@link LoadedSoulcraftKit} when the kit is `type: 'soulcraft'`.
122
+ * @throws {Error} If the loaded kit is not a Venue or Soulcraft kit.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const kit = await loadVenueKit('/app/kits/wicks-and-whiskers');
127
+ * if (kit.manifest.type === 'venue') {
128
+ * const { features } = kit.manifest.venue;
129
+ * } else if (kit.manifest.type === 'soulcraft') {
130
+ * const { features } = kit.manifest.venue ?? {};
131
+ * }
132
+ * ```
133
+ */
134
+ export async function loadVenueKit(kitDir: string): Promise<LoadedVenueKit | LoadedSoulcraftKit> {
135
+ const kit = await loadKitFromDirectory(kitDir);
136
+ if (kit.manifest.type === 'venue') {
137
+ return kit as LoadedVenueKit;
138
+ }
139
+ if (kit.manifest.type === 'soulcraft') {
140
+ return kit as LoadedSoulcraftKit;
141
+ }
142
+ throw new Error(
143
+ `Expected a Venue or Soulcraft kit at ${kitDir} but found type: "${kit.manifest.type}"`
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Discover and parse all SKILL.md files in a kit's `skills/` subdirectory.
149
+ *
150
+ * Scans for files matching `skills/{name}/SKILL.md`. Non-matching files and
151
+ * directories without a `SKILL.md` are silently skipped.
152
+ *
153
+ * @param kitDir - Absolute path to the kit directory root.
154
+ * @returns Array of parsed skills. Empty array if the `skills/` directory does not exist.
155
+ */
156
+ async function loadSkillsFromDirectory(kitDir: string): Promise<ParsedSkill[]> {
157
+ const skillsDir = join(kitDir, 'skills');
158
+
159
+ try {
160
+ const skillsStat = await stat(skillsDir);
161
+ if (!skillsStat.isDirectory()) return [];
162
+ } catch {
163
+ return [];
164
+ }
165
+
166
+ const skills: ParsedSkill[] = [];
167
+ const entries = await readdir(skillsDir, { withFileTypes: true });
168
+
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory()) continue;
171
+ const skillPath = join(skillsDir, entry.name, 'SKILL.md');
172
+
173
+ try {
174
+ const content = await readFile(skillPath, 'utf-8');
175
+ const skill = parseSkillFile(content, skillPath);
176
+ skills.push(skill);
177
+ } catch {
178
+ // SKILL.md does not exist or failed to parse — skip this entry
179
+ }
180
+ }
181
+
182
+ return skills;
183
+ }