@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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/bin/sigmaskills.js +7 -0
- package/manifest.json +28 -0
- package/package.json +42 -0
- package/registry/agent-hosts.json +2404 -0
- package/registry/schema.json +110 -0
- package/registry/skill-baselines.json +4 -0
- package/registry/source.json +6 -0
- package/sigmabrief/SKILL.md +56 -0
- package/sigmabrief/agents/openai.yaml +12 -0
- package/sigmabrief/references/brief-method.md +73 -0
- package/sigmabrief/references/prompt-contract.md +174 -0
- package/sigmaperformance/SKILL.md +118 -0
- package/sigmaperformance/agents/openai.yaml +12 -0
- package/sigmaperformance/references/audit-method.md +112 -0
- package/sigmaperformance/references/calibration.md +45 -0
- package/sigmaperformance/references/report-contract.md +103 -0
- package/sigmareview/SKILL.md +133 -0
- package/sigmareview/agents/openai.yaml +12 -0
- package/sigmareview/references/report-contract.md +217 -0
- package/sigmareview/references/review-method.md +233 -0
- package/sigmawrite/SKILL.md +45 -0
- package/sigmawrite/agents/openai.yaml +12 -0
- package/src/adoption.js +370 -0
- package/src/backup.js +398 -0
- package/src/catalog.js +211 -0
- package/src/cli.js +657 -0
- package/src/customization.js +344 -0
- package/src/destinations.js +491 -0
- package/src/interactive.js +959 -0
- package/src/links.js +157 -0
- package/src/plan.js +429 -0
- package/src/prepack.js +10 -0
- package/src/project-lock.js +169 -0
- package/src/purge.js +477 -0
- package/src/registry/automation-ci.js +411 -0
- package/src/registry/automation.js +554 -0
- package/src/registry/diff.js +149 -0
- package/src/registry/normalize.js +67 -0
- package/src/registry/parse.js +184 -0
- package/src/registry/sync.js +230 -0
- package/src/registry/validate.js +223 -0
- package/src/release-ci.js +12 -0
- package/src/release.js +837 -0
- package/src/restore.js +518 -0
- package/src/revision.js +82 -0
- package/src/state.js +480 -0
- package/src/status.js +469 -0
- package/src/transaction.js +636 -0
- package/src/uninstall.js +647 -0
- 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
|
+
}
|