@djordje-stojanovic/sigmaskills 0.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +342 -0
  4. package/bin/sigmaskills.js +7 -0
  5. package/manifest.json +28 -0
  6. package/package.json +42 -0
  7. package/registry/agent-hosts.json +2404 -0
  8. package/registry/schema.json +110 -0
  9. package/registry/skill-baselines.json +4 -0
  10. package/registry/source.json +6 -0
  11. package/sigmabrief/SKILL.md +56 -0
  12. package/sigmabrief/agents/openai.yaml +12 -0
  13. package/sigmabrief/references/brief-method.md +73 -0
  14. package/sigmabrief/references/prompt-contract.md +174 -0
  15. package/sigmaperformance/SKILL.md +118 -0
  16. package/sigmaperformance/agents/openai.yaml +12 -0
  17. package/sigmaperformance/references/audit-method.md +112 -0
  18. package/sigmaperformance/references/calibration.md +45 -0
  19. package/sigmaperformance/references/report-contract.md +103 -0
  20. package/sigmareview/SKILL.md +133 -0
  21. package/sigmareview/agents/openai.yaml +12 -0
  22. package/sigmareview/references/report-contract.md +217 -0
  23. package/sigmareview/references/review-method.md +233 -0
  24. package/sigmawrite/SKILL.md +45 -0
  25. package/sigmawrite/agents/openai.yaml +12 -0
  26. package/src/adoption.js +370 -0
  27. package/src/backup.js +398 -0
  28. package/src/catalog.js +211 -0
  29. package/src/cli.js +657 -0
  30. package/src/customization.js +344 -0
  31. package/src/destinations.js +491 -0
  32. package/src/interactive.js +959 -0
  33. package/src/links.js +157 -0
  34. package/src/plan.js +429 -0
  35. package/src/prepack.js +10 -0
  36. package/src/project-lock.js +169 -0
  37. package/src/purge.js +477 -0
  38. package/src/registry/automation-ci.js +411 -0
  39. package/src/registry/automation.js +554 -0
  40. package/src/registry/diff.js +149 -0
  41. package/src/registry/normalize.js +67 -0
  42. package/src/registry/parse.js +184 -0
  43. package/src/registry/sync.js +230 -0
  44. package/src/registry/validate.js +223 -0
  45. package/src/release-ci.js +12 -0
  46. package/src/release.js +837 -0
  47. package/src/restore.js +518 -0
  48. package/src/revision.js +82 -0
  49. package/src/state.js +480 -0
  50. package/src/status.js +469 -0
  51. package/src/transaction.js +636 -0
  52. package/src/uninstall.js +647 -0
  53. package/src/update.js +815 -0
package/src/backup.js ADDED
@@ -0,0 +1,398 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathExists } from './links.js';
5
+
6
+ export const BACKUP_METADATA_NAME = '.sigma-backup.json';
7
+ export const BACKUP_SCHEMA_VERSION = 1;
8
+
9
+ /**
10
+ * @param {string} stateDir
11
+ * @returns {string}
12
+ */
13
+ export function getBackupRoot(stateDir) {
14
+ return path.join(stateDir, 'backups');
15
+ }
16
+
17
+ function filesystemSafeStamp(now = new Date()) {
18
+ return now.toISOString().replace(/[:.]/g, '-');
19
+ }
20
+
21
+ function hashBytes(bytes) {
22
+ return crypto.createHash('sha256').update(bytes).digest('hex');
23
+ }
24
+
25
+ function isInsideRoot(root, absolutePath) {
26
+ const relative = path.relative(path.resolve(root), path.resolve(absolutePath));
27
+ return relative === '' || (Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative));
28
+ }
29
+
30
+ /**
31
+ * Inventory a skill tree without following symbolic links or junctions.
32
+ *
33
+ * @param {string} dir
34
+ * @returns {{ entries: Record<string, object> }}
35
+ */
36
+ export function inventorySkillTree(dir) {
37
+ const entries = {};
38
+ if (!pathExists(dir)) return { entries };
39
+ const top = fs.lstatSync(dir);
40
+ if (top.isSymbolicLink()) {
41
+ let target = '';
42
+ try {
43
+ target = fs.readlinkSync(dir);
44
+ } catch {
45
+ target = '';
46
+ }
47
+ return { entries: { '': { kind: 'symlink', target, escaped: true } } };
48
+ }
49
+ if (!top.isDirectory()) return { entries };
50
+
51
+ const visit = (current) => {
52
+ let dirents;
53
+ try {
54
+ dirents = fs.readdirSync(current, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const dirent of dirents) {
59
+ const full = path.join(current, dirent.name);
60
+ const rel = path.relative(dir, full).replace(/\\/g, '/');
61
+ let stat;
62
+ try {
63
+ stat = fs.lstatSync(full);
64
+ } catch {
65
+ continue;
66
+ }
67
+ if (dirent.name === BACKUP_METADATA_NAME) continue;
68
+ if (stat.isSymbolicLink()) {
69
+ let target = '';
70
+ try {
71
+ target = fs.readlinkSync(full);
72
+ } catch {
73
+ target = '';
74
+ }
75
+ const resolved = path.resolve(path.dirname(full), target);
76
+ entries[rel] = {
77
+ kind: 'symlink',
78
+ target,
79
+ escaped: !isInsideRoot(dir, resolved),
80
+ };
81
+ continue;
82
+ }
83
+ if (stat.isDirectory()) {
84
+ entries[rel] = { kind: 'directory' };
85
+ visit(full);
86
+ continue;
87
+ }
88
+ if (stat.isFile()) {
89
+ const bytes = fs.readFileSync(full);
90
+ entries[rel] = {
91
+ kind: 'file',
92
+ hash: hashBytes(bytes),
93
+ binary: bytes.includes(0),
94
+ };
95
+ }
96
+ }
97
+ };
98
+ visit(dir);
99
+ return { entries };
100
+ }
101
+
102
+ export function inventoriesMatch(left, right) {
103
+ const leftKeys = Object.keys(left.entries || {}).sort();
104
+ const rightKeys = Object.keys(right.entries || {}).sort();
105
+ if (leftKeys.join('\0') !== rightKeys.join('\0')) return false;
106
+ for (const key of leftKeys) {
107
+ const a = left.entries[key];
108
+ const b = right.entries[key];
109
+ if (a.kind !== b.kind) return false;
110
+ if (a.kind === 'file' && a.hash !== b.hash) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ export function copySkillTree(sourceDir, destDir) {
116
+ copyTreeNoFollow(sourceDir, destDir);
117
+ }
118
+
119
+ function treeSizeBytes(dir, inventory) {
120
+ let total = 0;
121
+ for (const [rel, entry] of Object.entries(inventory.entries || {})) {
122
+ if (entry.kind !== 'file') continue;
123
+ try {
124
+ total += fs.statSync(path.join(dir, ...rel.split('/'))).size;
125
+ } catch {
126
+ // Size is best-effort for preview; integrity still uses hashes.
127
+ }
128
+ }
129
+ return total;
130
+ }
131
+
132
+ /**
133
+ * @param {string} backupDir
134
+ * @returns {object}
135
+ */
136
+ export function readBackupMetadata(backupDir) {
137
+ const file = path.join(backupDir, BACKUP_METADATA_NAME);
138
+ if (!pathExists(file)) {
139
+ const err = new Error('schema-incompatible backup: missing metadata');
140
+ err.code = 'schema';
141
+ throw err;
142
+ }
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
146
+ } catch (cause) {
147
+ const err = new Error(`schema-incompatible backup: ${cause.message}`);
148
+ err.code = 'schema';
149
+ throw err;
150
+ }
151
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.schemaVersion !== 'number') {
152
+ const err = new Error('schema-incompatible backup: metadata is not a versioned object');
153
+ err.code = 'schema';
154
+ throw err;
155
+ }
156
+ if (parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
157
+ const err = new Error(
158
+ `schema-incompatible backup: schemaVersion ${parsed.schemaVersion} is not supported`,
159
+ );
160
+ err.code = 'schema';
161
+ throw err;
162
+ }
163
+ return parsed;
164
+ }
165
+
166
+ function writeBackupMetadata(backupDir, metadata) {
167
+ fs.writeFileSync(
168
+ path.join(backupDir, BACKUP_METADATA_NAME),
169
+ `${JSON.stringify(metadata, null, 2)}\n`,
170
+ 'utf8',
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Locate the retained backup directory for a skill.
176
+ *
177
+ * @param {object} params
178
+ * @returns {string | null}
179
+ */
180
+ export function findLatestBackupDir(params) {
181
+ const { stateDir, skillId, lastBackup } = params;
182
+ if (lastBackup) {
183
+ const fromState = path.isAbsolute(lastBackup)
184
+ ? lastBackup
185
+ : path.join(stateDir, lastBackup);
186
+ if (pathExists(fromState) && fs.lstatSync(fromState).isDirectory()) {
187
+ return fromState;
188
+ }
189
+ }
190
+ const dir = path.join(getBackupRoot(stateDir), skillId);
191
+ if (!pathExists(dir)) return null;
192
+ const stamps = fs.readdirSync(dir)
193
+ .map((name) => path.join(dir, name))
194
+ .filter((full) => {
195
+ try {
196
+ return fs.lstatSync(full).isDirectory();
197
+ } catch {
198
+ return false;
199
+ }
200
+ })
201
+ .sort();
202
+ return stamps.length > 0 ? stamps[stamps.length - 1] : null;
203
+ }
204
+
205
+ /**
206
+ * Integrity-check a retained backup before any restore write.
207
+ *
208
+ * @param {object} params
209
+ * @returns {{ metadata: object, inventory: object, sizeBytes: number }}
210
+ */
211
+ export function verifyBackupIntegrity(params) {
212
+ const { backupDir, skillId } = params;
213
+ if (!backupDir || !pathExists(backupDir)) {
214
+ const err = new Error(`missing backup for '${skillId}'`);
215
+ err.code = 'missing';
216
+ throw err;
217
+ }
218
+ const metadata = readBackupMetadata(backupDir);
219
+ if (metadata.skillId && metadata.skillId !== skillId) {
220
+ const err = new Error(
221
+ `stale-ownership: backup for '${metadata.skillId}' cannot restore '${skillId}'`,
222
+ );
223
+ err.code = 'stale-ownership';
224
+ throw err;
225
+ }
226
+ const inventory = inventorySkillTree(backupDir);
227
+ const expected = { entries: metadata.inventory || {} };
228
+ const expectedKeys = Object.keys(expected.entries).sort();
229
+ const actualKeys = Object.keys(inventory.entries || {}).sort();
230
+ for (const key of expectedKeys) {
231
+ if (!inventory.entries[key]) {
232
+ const err = new Error(`truncated backup for '${skillId}': missing '${key}'`);
233
+ err.code = 'truncated';
234
+ throw err;
235
+ }
236
+ }
237
+ if (actualKeys.join('\0') !== expectedKeys.join('\0') || !inventoriesMatch(expected, inventory)) {
238
+ const err = new Error(`tampered backup for '${skillId}'`);
239
+ err.code = 'tampered';
240
+ throw err;
241
+ }
242
+ return {
243
+ metadata,
244
+ inventory,
245
+ sizeBytes: typeof metadata.sizeBytes === 'number' ? metadata.sizeBytes : treeSizeBytes(backupDir, inventory),
246
+ };
247
+ }
248
+
249
+ function copyTreeNoFollow(sourceDir, destDir) {
250
+ fs.mkdirSync(destDir, { recursive: true });
251
+ const inventory = inventorySkillTree(sourceDir);
252
+ const keys = Object.keys(inventory.entries).sort((a, b) => {
253
+ const depth = a.split('/').length - b.split('/').length;
254
+ return depth !== 0 ? depth : a.localeCompare(b);
255
+ });
256
+ for (const rel of keys) {
257
+ const entry = inventory.entries[rel];
258
+ const from = path.join(sourceDir, ...rel.split('/'));
259
+ const to = path.join(destDir, ...rel.split('/'));
260
+ fs.mkdirSync(path.dirname(to), { recursive: true });
261
+ if (entry.kind === 'directory') {
262
+ fs.mkdirSync(to, { recursive: true });
263
+ } else if (entry.kind === 'file') {
264
+ fs.copyFileSync(from, to);
265
+ } else if (entry.kind === 'symlink') {
266
+ const type = process.platform === 'win32' ? 'junction' : 'dir';
267
+ fs.symlinkSync(entry.target, to, type);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Copy a complete skill tree into private Sigma state. Does not write beside the live skill.
274
+ *
275
+ * @param {object} params
276
+ * @returns {string} Backup directory
277
+ */
278
+ export function commitSkillBackup(params) {
279
+ const { stateDir, skillId, sourceDir, now = new Date() } = params;
280
+ if (!pathExists(sourceDir)) {
281
+ throw new Error(`cannot backup missing skill tree at ${sourceDir}`);
282
+ }
283
+ const dest = path.join(getBackupRoot(stateDir), skillId, filesystemSafeStamp(now));
284
+ fs.mkdirSync(dest, { recursive: true });
285
+ try {
286
+ copyTreeNoFollow(sourceDir, dest);
287
+ const sourceInventory = inventorySkillTree(sourceDir);
288
+ const backupInventory = inventorySkillTree(dest);
289
+ if (!inventoriesMatch(sourceInventory, backupInventory)) {
290
+ throw new Error('backup integrity check failed');
291
+ }
292
+ const ownership = params.ownership || {};
293
+ const createdAt = (now instanceof Date ? now : new Date(now)).toISOString();
294
+ writeBackupMetadata(dest, {
295
+ schemaVersion: BACKUP_SCHEMA_VERSION,
296
+ skillId,
297
+ scope: ownership.scope || null,
298
+ release: ownership.release || null,
299
+ revision: ownership.revision || null,
300
+ createdAt,
301
+ sizeBytes: treeSizeBytes(dest, backupInventory),
302
+ canonicalTarget: ownership.canonicalTarget || null,
303
+ method: ownership.method || null,
304
+ copies: Array.isArray(ownership.copies) ? ownership.copies : [],
305
+ ownedPaths: Array.isArray(ownership.ownedPaths) ? ownership.ownedPaths : [],
306
+ inventory: backupInventory.entries,
307
+ });
308
+ } catch (err) {
309
+ if (pathExists(dest)) fs.rmSync(dest, { recursive: true, force: true });
310
+ throw err;
311
+ }
312
+ return dest;
313
+ }
314
+
315
+ /**
316
+ * Keep only the newly committed backup for a skill after the operation succeeds.
317
+ * Failures leave extra backups in place and report them as cleanup debt.
318
+ *
319
+ * @param {object} params
320
+ * @returns {{ debt: string[] }}
321
+ */
322
+ export function pruneOlderBackups(params) {
323
+ const { stateDir, skillId, keepPath } = params;
324
+ const dir = path.join(getBackupRoot(stateDir), skillId);
325
+ const debt = [];
326
+ if (!pathExists(dir)) return { debt };
327
+ const keep = path.resolve(keepPath);
328
+ for (const name of fs.readdirSync(dir)) {
329
+ const full = path.resolve(dir, name);
330
+ if (full === keep) continue;
331
+ try {
332
+ fs.rmSync(full, { recursive: true, force: true });
333
+ } catch {
334
+ debt.push(path.relative(stateDir, full).replace(/\\/g, '/'));
335
+ }
336
+ }
337
+ return { debt };
338
+ }
339
+
340
+ /**
341
+ * @param {string} exportRoot
342
+ * @param {string} skillId
343
+ * @returns {string}
344
+ */
345
+ export function plannedExportDir(exportRoot, skillId) {
346
+ const primary = path.join(exportRoot, skillId);
347
+ if (!pathExists(primary)) return primary;
348
+ let n = 2;
349
+ while (pathExists(path.join(exportRoot, `${skillId}-${n}`))) {
350
+ n += 1;
351
+ }
352
+ return path.join(exportRoot, `${skillId}-${n}`);
353
+ }
354
+
355
+ /**
356
+ * @param {string} exportRoot
357
+ * @param {string} skillId
358
+ * @returns {string}
359
+ */
360
+ export function collisionSafeExportDir(exportRoot, skillId) {
361
+ fs.mkdirSync(exportRoot, { recursive: true });
362
+ return plannedExportDir(exportRoot, skillId);
363
+ }
364
+
365
+ /**
366
+ * Export a complete skill tree to a planned destination.
367
+ * Partial output is removed on failure. Existing collision targets are left untouched.
368
+ *
369
+ * @param {object} params
370
+ * @returns {string}
371
+ */
372
+ export function exportSkillTree(params) {
373
+ const { sourceDir, exportRoot, skillId, dest: plannedDest, refuseCollision = false, copyFn } = params;
374
+ const dest = plannedDest || collisionSafeExportDir(exportRoot, skillId);
375
+ if (!pathExists(sourceDir)) {
376
+ throw new Error(`failed to export '${skillId}' to '${dest}': missing source tree`);
377
+ }
378
+ if (refuseCollision && pathExists(dest)) {
379
+ throw new Error(`export collision at '${dest}'`);
380
+ }
381
+ const destExisted = pathExists(dest);
382
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
383
+ const staging = path.join(
384
+ path.dirname(dest),
385
+ `.sigma-export-${skillId}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
386
+ );
387
+ const copy = copyFn || ((from, to) => copyTreeNoFollow(from, to));
388
+ try {
389
+ copy(sourceDir, staging);
390
+ fs.renameSync(staging, dest);
391
+ return dest;
392
+ } catch (err) {
393
+ if (pathExists(staging)) fs.rmSync(staging, { recursive: true, force: true });
394
+ if (!destExisted && pathExists(dest)) fs.rmSync(dest, { recursive: true, force: true });
395
+ if (String(err.message || '').includes('collision')) throw err;
396
+ throw new Error(`failed to export '${skillId}' to '${dest}': ${err.message}`);
397
+ }
398
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { validateCustomizationBlock, escapeRegExp } from './customization.js';
5
+ import { computeSkillRevisionAndHashes } from './revision.js';
6
+
7
+ /**
8
+ * Locate repository / package root from a starting directory.
9
+ *
10
+ * @param {string} [startDir]
11
+ * @returns {string}
12
+ */
13
+ export function findPackageRoot(startDir) {
14
+ let current = startDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ while (current && current !== path.dirname(current)) {
16
+ if (fs.existsSync(path.join(current, 'package.json')) && fs.existsSync(path.join(current, 'manifest.json'))) {
17
+ return current;
18
+ }
19
+ current = path.dirname(current);
20
+ }
21
+ return startDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ }
23
+
24
+ /**
25
+ * Load and parse manifest.json from a directory.
26
+ *
27
+ * @param {string} rootDir
28
+ * @returns {object}
29
+ */
30
+ export function loadManifest(rootDir) {
31
+ const manifestPath = path.join(rootDir, 'manifest.json');
32
+ if (!fs.existsSync(manifestPath)) {
33
+ throw new Error(`missing manifest.json at ${manifestPath}`);
34
+ }
35
+ try {
36
+ const raw = fs.readFileSync(manifestPath, 'utf8');
37
+ return JSON.parse(raw);
38
+ } catch (err) {
39
+ throw new Error(`failed to parse manifest.json: ${err.message}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Validate manifest schema.
45
+ *
46
+ * @param {object} manifest
47
+ * @returns {boolean}
48
+ */
49
+ export function validateManifest(manifest) {
50
+ if (!manifest || typeof manifest !== 'object') {
51
+ throw new Error('invalid manifest: expected JSON object');
52
+ }
53
+ if (!manifest.schemaVersion || typeof manifest.schemaVersion !== 'number') {
54
+ throw new Error('invalid manifest: missing schemaVersion number');
55
+ }
56
+ if (!manifest.name || typeof manifest.name !== 'string') {
57
+ throw new Error('invalid manifest: missing name string');
58
+ }
59
+ if (!manifest.version || typeof manifest.version !== 'string') {
60
+ throw new Error('invalid manifest: missing version string');
61
+ }
62
+ if (!Array.isArray(manifest.skills) || manifest.skills.length === 0) {
63
+ throw new Error('invalid manifest: missing skills array');
64
+ }
65
+
66
+ for (const skill of manifest.skills) {
67
+ if (!skill.id || typeof skill.id !== 'string') {
68
+ throw new Error('invalid manifest skill: missing id');
69
+ }
70
+ if (!skill.title || typeof skill.title !== 'string') {
71
+ throw new Error(`invalid manifest skill ${skill.id}: missing title`);
72
+ }
73
+ }
74
+
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Parse frontmatter and body from a SKILL.md content string.
80
+ *
81
+ * @param {string} md
82
+ * @returns {{ name: string, description: string, rawFrontmatter: string, body: string }}
83
+ */
84
+ export function parseSkillFrontmatter(md) {
85
+ const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
86
+ if (!match) {
87
+ throw new Error('SKILL.md must start with YAML frontmatter delimited by ---');
88
+ }
89
+ const rawFrontmatter = match[1];
90
+ const body = match[2];
91
+ const name = rawFrontmatter.match(/^name:\s*(.+)\s*$/m)?.[1]?.trim();
92
+ const description = rawFrontmatter.match(/^description:\s*(.+)\s*$/m)?.[1]?.trim();
93
+
94
+ if (!name) {
95
+ throw new Error('frontmatter must include name');
96
+ }
97
+ if (!description) {
98
+ throw new Error('frontmatter must include description');
99
+ }
100
+
101
+ return { name, description, rawFrontmatter, body };
102
+ }
103
+
104
+ /**
105
+ * Validate an individual skill folder against its metadata and repo standards.
106
+ *
107
+ * @param {string} skillDir
108
+ * @param {object} skillMetadata
109
+ * @returns {object} Validated skill detail with revision and file hashes
110
+ */
111
+ export function validateSkill(skillDir, skillMetadata) {
112
+ if (!fs.existsSync(skillDir)) {
113
+ throw new Error(`skill directory does not exist: ${skillDir}`);
114
+ }
115
+
116
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
117
+ if (!fs.existsSync(skillMdPath)) {
118
+ throw new Error(`skill ${skillMetadata.id}: missing SKILL.md at ${skillMdPath}`);
119
+ }
120
+
121
+ const skillMdContent = fs.readFileSync(skillMdPath, 'utf8');
122
+ const { name, description, body } = parseSkillFrontmatter(skillMdContent);
123
+
124
+ if (name !== skillMetadata.id) {
125
+ throw new Error(
126
+ `skill ${skillMetadata.id}: frontmatter name '${name}' does not match skill id '${skillMetadata.id}'`,
127
+ );
128
+ }
129
+
130
+ const titleRegex = new RegExp(`^# ${escapeRegExp(skillMetadata.title)}\\s*$`, 'm');
131
+ if (!titleRegex.test(body)) {
132
+ throw new Error(
133
+ `skill ${skillMetadata.id}: SKILL.md body missing expected title header '# ${skillMetadata.title}'`,
134
+ );
135
+ }
136
+
137
+ // Validate customization block
138
+ validateCustomizationBlock(skillMdContent, skillMetadata.id);
139
+
140
+ // Validate agents/openai.yaml
141
+ const yamlPath = path.join(skillDir, 'agents', 'openai.yaml');
142
+ if (!fs.existsSync(yamlPath)) {
143
+ throw new Error(`skill ${skillMetadata.id}: missing agents/openai.yaml at ${yamlPath}`);
144
+ }
145
+ const yamlContent = fs.readFileSync(yamlPath, 'utf8');
146
+ if (!yamlContent.includes(`display_name: ${skillMetadata.title}`)) {
147
+ throw new Error(
148
+ `skill ${skillMetadata.id}: agents/openai.yaml missing display_name '${skillMetadata.title}'`,
149
+ );
150
+ }
151
+ if (!yamlContent.includes(`$${skillMetadata.id}`)) {
152
+ throw new Error(
153
+ `skill ${skillMetadata.id}: agents/openai.yaml default_prompt must reference '$${skillMetadata.id}'`,
154
+ );
155
+ }
156
+
157
+ // Validate references directory if required
158
+ if (skillMetadata.needsReferences) {
159
+ const refDir = path.join(skillDir, 'references');
160
+ if (!fs.existsSync(refDir)) {
161
+ throw new Error(`skill ${skillMetadata.id}: missing required references/ directory`);
162
+ }
163
+ const refFiles = fs.readdirSync(refDir).filter((f) => f.endsWith('.md'));
164
+ if (refFiles.length === 0) {
165
+ throw new Error(`skill ${skillMetadata.id}: references/ directory must contain at least one .md file`);
166
+ }
167
+ }
168
+
169
+ const { revision, files } = computeSkillRevisionAndHashes(skillDir);
170
+
171
+ return {
172
+ id: skillMetadata.id,
173
+ title: skillMetadata.title,
174
+ description,
175
+ needsReferences: Boolean(skillMetadata.needsReferences),
176
+ revision,
177
+ files,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Validate all skills in a skill pack against a manifest.
183
+ *
184
+ * @param {string} rootDir
185
+ * @param {object} manifest
186
+ * @returns {object[]}
187
+ */
188
+ export function validateSkillPack(rootDir, manifest) {
189
+ validateManifest(manifest);
190
+ const validatedSkills = [];
191
+
192
+ for (const skillMetadata of manifest.skills) {
193
+ const skillDir = path.join(rootDir, skillMetadata.id);
194
+ const validated = validateSkill(skillDir, skillMetadata);
195
+ validatedSkills.push(validated);
196
+ }
197
+
198
+ return validatedSkills;
199
+ }
200
+
201
+ /**
202
+ * Load manifest, validate all skills, and produce full verified catalog.
203
+ *
204
+ * @param {string} [rootDir]
205
+ * @returns {{ manifest: object, skills: object[] }}
206
+ */
207
+ export function getCatalog(rootDir = findPackageRoot()) {
208
+ const manifest = loadManifest(rootDir);
209
+ const skills = validateSkillPack(rootDir, manifest);
210
+ return { manifest, skills };
211
+ }