@knolo/cli 6.2.0 → 6.2.2

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/bin/skill.mjs ADDED
@@ -0,0 +1,1308 @@
1
+ /**
2
+ * knolo skill
3
+ *
4
+ * Creates a Knolo skill pack: guidance plus an optional pinned V5 image.
5
+ * The pack names no host and no home directory. The Knolo hub stores packs.
6
+ * It does not run a model and it does not answer questions.
7
+ * SATISFIED means evidence requirements were met.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
12
+ import { homedir, tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import {
16
+ normalizeRegistryUrl,
17
+ registryApiUrl,
18
+ resolveRegistryUrl,
19
+ } from './registry/config.mjs';
20
+ import { loadCredentials } from './registry/credentials.mjs';
21
+ import { getJson, postJson, putBytes } from './registry/http.mjs';
22
+ import { findOnPath } from './setup.mjs';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ export const SKILL_PACK_FORMAT = 'knolo-skill-pack-v1';
27
+ export const MAX_KNOWLEDGE_FILES = 100;
28
+ export const MAX_KNOWLEDGE_BYTES = 1024 * 1024;
29
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
30
+ const EXTENSIONS = new Set(['.md', '.txt', '.json']);
31
+
32
+ const USAGE = `Usage: knolo skill <init|check|pack|publish|install|search|info|list> [options]
33
+
34
+ knolo skill init <name> [--dir <parent>]
35
+ knolo skill check [<dir>] [--query <text>] [--json]
36
+ knolo skill pack [<dir>] [--out <dir>] [--json]
37
+ knolo skill publish <pack> [--registry <url>] [--json]
38
+ knolo skill publish <pack> --hub <dir> [--json]
39
+ knolo skill install <publisher>/<name> [--dir <path>] [--registry <url>] [--json]
40
+ knolo skill install <name> --hub <dir> [--dir <path>] [--json]
41
+ knolo skill install --pack <pack> [--dir <path>] [--json]
42
+ knolo skill search <query> [--registry <url>] [--json]
43
+ knolo skill info <publisher>/<name> [--registry <url>] [--json]
44
+ knolo skill list [--registry <url>] [--json]
45
+
46
+ A skill is guidance for the host agent. Knolo does not call a model.
47
+ check searches the skill's own knowledge and returns a receipt id.
48
+ A receipt is an integrity check. It does not mean a claim is proven.
49
+ SATISFIED means the evidence requirements were met.
50
+ publish sends the pack to https://hub.knolo.dev unless --hub names a
51
+ local directory. install checks the pin before it writes. It writes into
52
+ --dir, or into ~/.knolo/skills when --dir is omitted. A pin mismatch
53
+ writes nothing. When that folder is a skills root and knolo-box is on
54
+ PATH, install runs one knolo-box build.
55
+ list prints every publisher/name from the public catalog. It does not
56
+ send a token.
57
+ The hub stores packs. It does not answer questions.`;
58
+
59
+ export async function runSkillCli(argv) {
60
+ const { positionals, flags } = parseArgs(argv);
61
+ const sub = positionals[0] ?? '';
62
+ if (!sub || sub === 'help' || flags.help) {
63
+ console.log(USAGE);
64
+ return;
65
+ }
66
+ if (sub === 'init') return initSkill(positionals[1], flags);
67
+ if (sub === 'check') return checkSkill(positionals[1], flags);
68
+ if (sub === 'pack') return packSkill(positionals[1], flags);
69
+ if (sub === 'publish') return publishSkill(positionals[1], flags);
70
+ if (sub === 'install') return installSkill(positionals[1], flags);
71
+ if (sub === 'search') return searchSkills(positionals.slice(1).join(' '), flags);
72
+ if (sub === 'info') return infoSkill(positionals[1], flags);
73
+ if (sub === 'list') return listSkills(flags);
74
+ throw new Error(`Unknown skill command "${sub}".\n${USAGE}`);
75
+ }
76
+
77
+ export function skillNameError(name) {
78
+ if (typeof name !== 'string' || !NAME.test(name)) {
79
+ return 'SKILL_NAME: use lowercase letters, digits, and single hyphens.';
80
+ }
81
+ return null;
82
+ }
83
+
84
+ const ABSOLUTE_PATH =
85
+ /(?:^|[\s`"'(])(?:~\/|\/(?:home|Users|opt|var|tmp)\/|[A-Za-z]:\\)/u;
86
+
87
+ function hostLayoutError(text) {
88
+ if (ABSOLUTE_PATH.test(text)) {
89
+ return 'SKILL_GUIDANCE: a skill pack cannot name a home directory or an absolute path.';
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function guidanceError(body) {
95
+ const layout = hostLayoutError(body);
96
+ if (layout) return layout;
97
+ const patterns = [
98
+ /\b(?:skip|ignore|omit|bypass)\b[^.\n]{0,48}\b(?:pin|receipt)\b/giu,
99
+ /\bwithout (?:a |the )?(?:pin|receipt)\b/giu,
100
+ /\b(?:do not|don't|never) (?:cite|include|return)\b[^.\n]{0,48}\breceipt\b/giu,
101
+ ];
102
+ for (const pattern of patterns) pattern.lastIndex = 0;
103
+ for (const pattern of patterns) {
104
+ for (const match of body.matchAll(pattern)) {
105
+ const start = Math.max(0, match.index - 20);
106
+ const prefix = body.slice(start, match.index).toLowerCase();
107
+ const negated = /\b(?:not|never)\s*$/u.test(prefix);
108
+ const tellsYouToDropTheReceipt = /\b(?:cite|include|return)\b/iu.test(
109
+ match[0]
110
+ );
111
+ if (negated && !tellsYouToDropTheReceipt) continue;
112
+ return `SKILL_GUIDANCE: the skill tells the agent to skip a pin or a receipt (${match[0]}).`;
113
+ }
114
+ }
115
+ if (!/\breceipt\b/iu.test(body)) {
116
+ return 'SKILL_GUIDANCE: the skill must tell the agent to cite a receipt.';
117
+ }
118
+ if (
119
+ !/\bintegrity\b/iu.test(body) &&
120
+ !/does not mean a claim is proven/iu.test(body)
121
+ ) {
122
+ return 'SKILL_GUIDANCE: the skill must say that a receipt is an integrity check.';
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function initSkill(name, flags) {
128
+ const nameError = skillNameError(name);
129
+ if (nameError) throw new Error(nameError);
130
+ const parent = path.resolve(process.cwd(), flags.dir ?? '.');
131
+ const dir = path.join(parent, name);
132
+ return prepareSkillDir(dir, name, flags);
133
+ }
134
+
135
+ async function prepareSkillDir(dir, name, flags) {
136
+ try {
137
+ await lstat(dir);
138
+ throw new Error(`SKILL_NAME: ${dir} already exists.`);
139
+ } catch (error) {
140
+ if (error && error.code !== 'ENOENT') throw error;
141
+ }
142
+ await mkdir(path.join(dir, 'knowledge'), { recursive: true });
143
+ const skill = skillTemplate(name);
144
+ await writeFile(path.join(dir, 'SKILL.md'), skill, { mode: 0o644 });
145
+ const summary = { ok: true, name, dir };
146
+ if (flags.json) console.log(JSON.stringify(summary));
147
+ else console.log(`wrote ${dir}`);
148
+ }
149
+
150
+ function skillTemplate(name) {
151
+ return `---
152
+ name: ${name}
153
+ description: Cite the pinned Knolo image for ${name} before answering.
154
+ ---
155
+
156
+ # ${name}
157
+
158
+ The host owns the model. This skill does not call one.
159
+
160
+ 1. Search the pinned image for this skill.
161
+ 2. Include the receipt id in the reply.
162
+ 3. A receipt is an integrity check. It does not mean a claim is proven. SATISFIED means the evidence requirements were met.
163
+ 4. Do not skip the pin. Do not invent a passage.
164
+ `;
165
+ }
166
+
167
+ async function checkSkill(dirArg, flags) {
168
+ const loaded = await loadSkillDir(dirArg);
169
+ const report = await inspectSkill(loaded, flags.query);
170
+ if (flags.json) console.log(JSON.stringify(report));
171
+ else {
172
+ console.log(`skill ${report.name}`);
173
+ console.log(`receipt ${report.receiptId ?? 'none'}`);
174
+ console.log(`hits ${report.hits}`);
175
+ if (report.stateRoot) console.log(`state root ${report.stateRoot}`);
176
+ }
177
+ }
178
+
179
+ async function packSkill(dirArg, flags) {
180
+ const loaded = await loadSkillDir(dirArg);
181
+ const built = await buildSkill(loaded, flags.query);
182
+ const out = path.resolve(
183
+ process.cwd(),
184
+ flags.out ??
185
+ path.join(loaded.dir, 'dist', `${loaded.parsed.name}.knolo-skill`)
186
+ );
187
+ try {
188
+ await lstat(out);
189
+ throw new Error(`SKILL_PACK: refusing to overwrite ${out}.`);
190
+ } catch (error) {
191
+ if (error && error.code !== 'ENOENT') throw error;
192
+ }
193
+ await writePack(out, built);
194
+ const summary = packSummary(built, out);
195
+ if (flags.json) console.log(JSON.stringify(summary));
196
+ else {
197
+ console.log(`skill ${summary.name}`);
198
+ console.log(`wrote ${out}`);
199
+ console.log(`skill sha256 ${summary.skillSha256}`);
200
+ if (summary.image) {
201
+ console.log(`image sha256 ${summary.image.sha256}`);
202
+ console.log(`state root ${summary.image.stateRoot}`);
203
+ }
204
+ }
205
+ }
206
+
207
+ async function publishSkill(packArg, flags) {
208
+ if (!packArg) throw new Error('SKILL_HUB: pass the pack directory.');
209
+ if (flags.hub) {
210
+ if (flags.registry) {
211
+ throw new Error(
212
+ 'SKILL_HUB: --hub is a local directory. Use --registry for the Knolo hub.'
213
+ );
214
+ }
215
+ return publishSkillDirectory(packArg, flags);
216
+ }
217
+ const credentials = await loadCredentials();
218
+ const registry = resolveSkillRegistry(flags.registry, credentials.registry);
219
+ const summary = await publishSkillToRegistry(
220
+ path.resolve(process.cwd(), packArg),
221
+ { registry, token: credentials.token }
222
+ );
223
+ if (flags.json) console.log(JSON.stringify(summary));
224
+ else if (summary.unchanged) console.log(`unchanged ${summary.name}`);
225
+ else console.log(`published ${summary.name}`);
226
+ }
227
+
228
+ async function publishSkillDirectory(packArg, flags) {
229
+ const packDir = path.resolve(process.cwd(), packArg ?? '');
230
+ if (!packArg) throw new Error('SKILL_HUB: pass the pack directory.');
231
+ const pack = await readVerifiedPack(packDir);
232
+ const hub = path.resolve(process.cwd(), flags.hub);
233
+ const dest = path.join(hub, pack.manifest.name);
234
+ await rejectHostPath(dest);
235
+ try {
236
+ const existing = await readVerifiedPack(dest);
237
+ if (
238
+ existing.manifest.skillSha256 !== pack.manifest.skillSha256 ||
239
+ !sameImage(existing.manifest.image, pack.manifest.image)
240
+ ) {
241
+ throw new Error(
242
+ `SKILL_HUB: ${pack.manifest.name} is already in the catalog with a different pin.`
243
+ );
244
+ }
245
+ const summary = {
246
+ ok: true,
247
+ name: pack.manifest.name,
248
+ hub: dest,
249
+ unchanged: true,
250
+ };
251
+ if (flags.json) console.log(JSON.stringify(summary));
252
+ else console.log(`unchanged ${dest}`);
253
+ return;
254
+ } catch (error) {
255
+ if (!(error && error.code === 'ENOENT')) throw error;
256
+ }
257
+ await writePackFiles(dest, pack);
258
+ const summary = {
259
+ ok: true,
260
+ name: pack.manifest.name,
261
+ hub: dest,
262
+ unchanged: false,
263
+ };
264
+ if (flags.json) console.log(JSON.stringify(summary));
265
+ else console.log(`published ${dest}`);
266
+ }
267
+
268
+ export async function installSkill(nameArg, flags) {
269
+ const env = flags.env ?? process.env;
270
+ const home = flags.homeDir || env.HOME || homedir();
271
+ const destRoot = path.resolve(
272
+ flags.dir ?? path.join(home, '.knolo', 'skills')
273
+ );
274
+ if (flags.hub && flags.registry) {
275
+ throw new Error(
276
+ 'SKILL_HUB: --hub is a local directory. Use --registry for the Knolo hub.'
277
+ );
278
+ }
279
+ if (!flags.pack && !flags.hub) {
280
+ if (flags.hub === undefined && nameArg && nameArg.includes('/')) {
281
+ const registry = resolveRegistryUrl({
282
+ value: flags.registry,
283
+ env: flags.env ?? process.env,
284
+ });
285
+ const summary = await installSkillFromRegistry(nameArg, destRoot, {
286
+ registry,
287
+ fetchImpl: flags.fetchImpl ?? globalThis.fetch,
288
+ });
289
+ return reportInstall(summary, destRoot, flags);
290
+ }
291
+ throw new Error(
292
+ 'SKILL_HUB: pass publisher/name, or --hub for a directory catalog.'
293
+ );
294
+ }
295
+ let pack;
296
+ if (flags.pack) {
297
+ pack = await readVerifiedPack(path.resolve(process.cwd(), flags.pack));
298
+ } else {
299
+ if (!nameArg) throw new Error('SKILL_HUB: pass a skill name or --pack.');
300
+ if (!flags.hub) throw new Error('Missing --hub');
301
+ const nameError = skillNameError(nameArg);
302
+ if (nameError) throw new Error(nameError);
303
+ pack = await readVerifiedPack(
304
+ path.join(path.resolve(process.cwd(), flags.hub), nameArg)
305
+ );
306
+ if (pack.manifest.name !== nameArg) {
307
+ throw new Error(
308
+ `SKILL_HUB: catalog entry ${nameArg} is a pack named ${pack.manifest.name}.`
309
+ );
310
+ }
311
+ }
312
+ const dest = path.join(destRoot, pack.manifest.name);
313
+ await rejectHostPath(dest);
314
+ try {
315
+ await lstat(dest);
316
+ throw new Error(`SKILL_PACK: refusing to overwrite ${dest}.`);
317
+ } catch (error) {
318
+ if (error && error.code !== 'ENOENT') throw error;
319
+ }
320
+ await writePackFiles(dest, pack);
321
+ const summary = { ok: true, name: pack.manifest.name, dir: dest };
322
+ return reportInstall(summary, destRoot, flags);
323
+ }
324
+
325
+ async function reportInstall(summary, destRoot, flags) {
326
+ const print = flags.print ?? console.log;
327
+ if (flags.json) print(JSON.stringify(summary));
328
+ else print(`installed ${summary.dir}`);
329
+ await indexInstalledSkill(destRoot, flags);
330
+ return summary;
331
+ }
332
+
333
+ async function indexInstalledSkill(destRoot, flags) {
334
+ const printError = flags.printError ?? console.error;
335
+ const env = flags.env ?? process.env;
336
+ const home = flags.homeDir || env.HOME || homedir();
337
+ const configPath = path.join(home, '.knolo', 'box', 'box.config.json');
338
+ let config;
339
+ try {
340
+ config = JSON.parse(await readFile(configPath, 'utf8'));
341
+ } catch {
342
+ config = null;
343
+ }
344
+ const roots = skillRootsFromConfig(config, env, home);
345
+ const dest = await canonicalDir(destRoot);
346
+ let indexed = false;
347
+ for (const root of roots) {
348
+ if (typeof root !== 'string' || root.length === 0) continue;
349
+ if ((await canonicalDir(root)) === dest) {
350
+ indexed = true;
351
+ break;
352
+ }
353
+ }
354
+ if (!indexed) {
355
+ printError(
356
+ 'This folder is not a Knolo skills root, so the install was not indexed.'
357
+ );
358
+ return;
359
+ }
360
+ const box = await findOnPath('knolo-box', env.PATH);
361
+ if (!box) {
362
+ printError('knolo-box is not on PATH. The install was not indexed.');
363
+ return;
364
+ }
365
+ const status = await spawnBox(box, ['build', '--config', configPath], env);
366
+ if (status !== 0) {
367
+ printError(
368
+ `knolo-box build exited with status ${status}. The install was not indexed.`
369
+ );
370
+ }
371
+ }
372
+
373
+ function skillRootsFromConfig(config, env, home) {
374
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return [];
375
+ const fallbackGrok = path.resolve(
376
+ expandBoxPath(env.GROK_HOME?.trim() || path.join(home, '.grok'), home, '')
377
+ );
378
+ const grokHome = path.resolve(
379
+ expandBoxPath(
380
+ typeof config.grokHome === 'string' ? config.grokHome : fallbackGrok,
381
+ home,
382
+ fallbackGrok
383
+ )
384
+ );
385
+ const scopes = config.scopes;
386
+ if (scopes !== undefined) {
387
+ if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) {
388
+ return [];
389
+ }
390
+ }
391
+ const skills =
392
+ scopes && typeof scopes === 'object' ? scopes.skills : undefined;
393
+ if (skills === undefined) {
394
+ return [
395
+ path.join(grokHome, 'skills'),
396
+ path.join(grokHome, 'bundled', 'skills'),
397
+ ];
398
+ }
399
+ if (!skills || typeof skills !== 'object' || Array.isArray(skills)) return [];
400
+ if (!Array.isArray(skills.roots)) return [];
401
+ const roots = [];
402
+ for (const item of skills.roots) {
403
+ if (typeof item !== 'string' || item.length === 0) continue;
404
+ roots.push(path.resolve(expandBoxPath(item, home, grokHome)));
405
+ }
406
+ return roots;
407
+ }
408
+
409
+ function expandBoxPath(input, home, grokHome) {
410
+ const withHome = input
411
+ .replaceAll('${GROK_HOME}', grokHome)
412
+ .replaceAll('$GROK_HOME', grokHome);
413
+ if (withHome === '~') return home;
414
+ if (withHome.startsWith('~/')) return path.join(home, withHome.slice(2));
415
+ return withHome;
416
+ }
417
+
418
+ async function canonicalDir(candidate) {
419
+ try {
420
+ return await realpath(candidate);
421
+ } catch {
422
+ return path.resolve(candidate);
423
+ }
424
+ }
425
+
426
+ function spawnBox(command, args, env) {
427
+ return new Promise((resolve, reject) => {
428
+ const child = spawn(command, args, {
429
+ env,
430
+ stdio: ['ignore', 'ignore', 'inherit'],
431
+ });
432
+ child.on('error', reject);
433
+ child.on('exit', (code, signal) => {
434
+ if (signal) reject(new Error(`knolo-box exited from ${signal}.`));
435
+ else resolve(code ?? 1);
436
+ });
437
+ });
438
+ }
439
+
440
+ async function loadSkillDir(dirArg) {
441
+ const dir = path.resolve(process.cwd(), dirArg ?? '.');
442
+ const raw = await readFile(path.join(dir, 'SKILL.md'));
443
+ const parsed = parseSkill(raw.toString('utf8'));
444
+ if (path.basename(dir) !== parsed.name) {
445
+ throw new Error(
446
+ `SKILL_NAME: directory ${path.basename(dir)} does not match frontmatter name ${parsed.name}.`
447
+ );
448
+ }
449
+ return { dir, raw, parsed };
450
+ }
451
+
452
+ function parseSkill(text) {
453
+ if (!text.startsWith('---\n')) {
454
+ throw new Error(
455
+ 'SKILL_FRONTMATTER: SKILL.md must start with a frontmatter block.'
456
+ );
457
+ }
458
+ const end = text.indexOf('\n---\n', 4);
459
+ if (end < 0)
460
+ throw new Error('SKILL_FRONTMATTER: SKILL.md frontmatter does not close.');
461
+ const fields = {};
462
+ for (const line of text.slice(4, end).split('\n')) {
463
+ if (!line.trim()) continue;
464
+ const match = /^([a-z]+):[ ]*(.*)$/u.exec(line);
465
+ if (!match) throw new Error(`SKILL_FRONTMATTER: cannot read "${line}".`);
466
+ fields[match[1]] = match[2].trim();
467
+ }
468
+ const keys = Object.keys(fields).sort();
469
+ if (keys.join(',') !== 'description,name') {
470
+ throw new Error('SKILL_FRONTMATTER: use only name and description.');
471
+ }
472
+ const nameError = skillNameError(fields.name);
473
+ if (nameError) throw new Error(nameError);
474
+ if (!fields.description)
475
+ throw new Error('SKILL_FRONTMATTER: description is empty.');
476
+ const described = hostLayoutError(fields.description);
477
+ if (described) throw new Error(described);
478
+ const body = text.slice(end + '\n---\n'.length);
479
+ const guidance = guidanceError(body);
480
+ if (guidance) throw new Error(guidance);
481
+ return { name: fields.name, description: fields.description, body };
482
+ }
483
+
484
+ async function inspectSkill(loaded, queryFlag) {
485
+ const files = await listKnowledge(loaded.dir);
486
+ if (files.length === 0) {
487
+ return {
488
+ ok: true,
489
+ name: loaded.parsed.name,
490
+ receiptId: null,
491
+ hits: 0,
492
+ stateRoot: null,
493
+ };
494
+ }
495
+ const built = await buildFromFiles(loaded, files, queryFlag);
496
+ if (built.hits < 1) {
497
+ throw new Error(
498
+ 'SKILL_CHECK: the knowledge search returned no hits. Pass --query with words from the source.'
499
+ );
500
+ }
501
+ return {
502
+ ok: true,
503
+ name: loaded.parsed.name,
504
+ receiptId: built.receiptId,
505
+ hits: built.hits,
506
+ stateRoot: built.image.stateRoot,
507
+ };
508
+ }
509
+
510
+ async function buildSkill(loaded, queryFlag) {
511
+ const files = await listKnowledge(loaded.dir);
512
+ return buildFromFiles(loaded, files, queryFlag);
513
+ }
514
+
515
+ async function buildFromFiles(loaded, files, queryFlag) {
516
+ const policy = skillPolicy(loaded.parsed.name);
517
+ const policyBytes = Buffer.from(`${JSON.stringify(policy, null, 2)}\n`);
518
+ let image = null;
519
+ let hits = 0;
520
+ let receiptId = null;
521
+ if (files.length > 0) {
522
+ const core = await loadCore();
523
+ const objects = [];
524
+ for (const file of files) {
525
+ const bytes = await readFile(file.full);
526
+ objects.push({
527
+ kind: 'chunk',
528
+ bytes,
529
+ meta: { namespace: loaded.parsed.name, source: file.source },
530
+ });
531
+ }
532
+ image = core.createKnowledgeImageV5({
533
+ actor: 'knolo-skill',
534
+ policy,
535
+ objects,
536
+ });
537
+ const query = queryFlag ?? (await defaultQuery(files));
538
+ assertQuery(query);
539
+ const result = core.queryKnowledgeImageV5(
540
+ image,
541
+ `FROM chunk SEARCH ${JSON.stringify(query)} LIMIT 5`
542
+ );
543
+ hits = result.hits.length;
544
+ if (hits < 1) {
545
+ throw new Error(
546
+ 'SKILL_CHECK: the knowledge search returned no hits. Pass --query with words from the source.'
547
+ );
548
+ }
549
+ receiptId = `sr_${createHash('sha256')
550
+ .update(`${image.stateRoot}\n${result.resultRoot}`)
551
+ .digest('hex')
552
+ .slice(0, 24)}`;
553
+ }
554
+ const manifest = {
555
+ format: SKILL_PACK_FORMAT,
556
+ name: loaded.parsed.name,
557
+ description: loaded.parsed.description,
558
+ skillSha256: sha256(loaded.raw),
559
+ policySha256: sha256(policyBytes),
560
+ image: image
561
+ ? { sha256: sha256(image.bytes), stateRoot: image.stateRoot }
562
+ : null,
563
+ };
564
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
565
+ assertNoHost(manifestBytes.toString('utf8'));
566
+ assertNoHost(policyBytes.toString('utf8'));
567
+ return {
568
+ manifest,
569
+ manifestBytes,
570
+ skillBytes: loaded.raw,
571
+ policyBytes,
572
+ imageBytes: image ? Buffer.from(image.bytes) : null,
573
+ image,
574
+ hits,
575
+ receiptId,
576
+ };
577
+ }
578
+
579
+ function skillPolicy(name) {
580
+ const principal = `skill:${name}`;
581
+ return {
582
+ version: 1,
583
+ default: 'deny',
584
+ rules: [
585
+ { effect: 'allow', action: 'query', principal, kind: 'chunk' },
586
+ { effect: 'allow', action: 'read', principal, kind: 'chunk' },
587
+ ],
588
+ };
589
+ }
590
+
591
+ async function listKnowledge(dir) {
592
+ const root = path.join(dir, 'knowledge');
593
+ const info = await lstat(root);
594
+ if (!info.isDirectory() || info.isSymbolicLink()) {
595
+ throw new Error('SKILL_CHECK: knowledge/ must be a real directory.');
596
+ }
597
+ const files = [];
598
+ async function walk(current) {
599
+ const entries = await readdir(current, { withFileTypes: true });
600
+ entries.sort((left, right) => compareText(left.name, right.name));
601
+ for (const entry of entries) {
602
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
603
+ const full = path.join(current, entry.name);
604
+ const stat = await lstat(full);
605
+ if (stat.isSymbolicLink()) {
606
+ throw new Error(`SKILL_CHECK: refusing symlink ${entry.name}.`);
607
+ }
608
+ if (stat.isDirectory()) {
609
+ await walk(full);
610
+ continue;
611
+ }
612
+ if (!stat.isFile()) continue;
613
+ if (!EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
614
+ if (stat.size > MAX_KNOWLEDGE_BYTES) {
615
+ throw new Error(`SKILL_CHECK: ${entry.name} is larger than 1 MiB.`);
616
+ }
617
+ const source = path.relative(root, full).split(path.sep).join('/');
618
+ files.push({ full, source });
619
+ }
620
+ }
621
+ await walk(root);
622
+ if (files.length > MAX_KNOWLEDGE_FILES) {
623
+ throw new Error(
624
+ `SKILL_CHECK: a skill can contain ${MAX_KNOWLEDGE_FILES} knowledge files.`
625
+ );
626
+ }
627
+ files.sort((left, right) => compareText(left.source, right.source));
628
+ return files;
629
+ }
630
+
631
+ async function defaultQuery(files) {
632
+ const text = (await readFile(files[0].full)).toString('utf8');
633
+ const words = text
634
+ .toLowerCase()
635
+ .split(/[^\p{L}\p{N}]+/u)
636
+ .filter((word) => word.length >= 3)
637
+ .slice(0, 3);
638
+ if (words.length === 0) {
639
+ throw new Error(
640
+ 'SKILL_CHECK: pass --query. The first knowledge file has no words.'
641
+ );
642
+ }
643
+ return words.join(' ');
644
+ }
645
+
646
+ function assertQuery(query) {
647
+ if (
648
+ typeof query !== 'string' ||
649
+ !/^[\p{L}\p{N} ]+$/u.test(query) ||
650
+ !query.trim()
651
+ ) {
652
+ throw new Error('SKILL_CHECK: --query must be words separated by spaces.');
653
+ }
654
+ }
655
+
656
+ async function writePack(out, built) {
657
+ await writePackFiles(out, {
658
+ manifestBytes: built.manifestBytes,
659
+ skillBytes: built.skillBytes,
660
+ policyBytes: built.policyBytes,
661
+ imageBytes: built.imageBytes,
662
+ });
663
+ }
664
+
665
+ async function writePackFiles(out, pack) {
666
+ await mkdir(out, { recursive: true });
667
+ await writeFile(path.join(out, 'manifest.json'), pack.manifestBytes, {
668
+ mode: 0o644,
669
+ });
670
+ await writeFile(path.join(out, 'SKILL.md'), pack.skillBytes, { mode: 0o644 });
671
+ await writeFile(path.join(out, 'policy.json'), pack.policyBytes, {
672
+ mode: 0o644,
673
+ });
674
+ if (pack.imageBytes) {
675
+ await writeFile(path.join(out, 'knowledge.v5'), pack.imageBytes, {
676
+ mode: 0o644,
677
+ });
678
+ }
679
+ }
680
+
681
+ async function readVerifiedPack(packDir) {
682
+ let manifestBytes;
683
+ let skillBytes;
684
+ let policyBytes;
685
+ try {
686
+ manifestBytes = await readFile(path.join(packDir, 'manifest.json'));
687
+ skillBytes = await readFile(path.join(packDir, 'SKILL.md'));
688
+ policyBytes = await readFile(path.join(packDir, 'policy.json'));
689
+ } catch (error) {
690
+ if (error && error.code === 'ENOENT') {
691
+ const missing = new Error(`SKILL_HUB: no skill pack at ${packDir}.`);
692
+ missing.code = 'ENOENT';
693
+ throw missing;
694
+ }
695
+ throw error;
696
+ }
697
+ let manifest;
698
+ try {
699
+ manifest = JSON.parse(manifestBytes.toString('utf8'));
700
+ } catch {
701
+ throw new Error('SKILL_PIN: manifest.json is not valid JSON.');
702
+ }
703
+ if (!manifest || manifest.format !== SKILL_PACK_FORMAT) {
704
+ throw new Error('SKILL_PIN: manifest format is not a Knolo skill pack.');
705
+ }
706
+ const nameError = skillNameError(manifest.name);
707
+ if (nameError) throw new Error(nameError);
708
+ if (sha256(skillBytes) !== manifest.skillSha256) {
709
+ throw new Error('SKILL_PIN: SKILL.md does not match its SHA-256 pin.');
710
+ }
711
+ if (sha256(policyBytes) !== manifest.policySha256) {
712
+ throw new Error('SKILL_PIN: policy.json does not match its SHA-256 pin.');
713
+ }
714
+ const parsed = parseSkill(skillBytes.toString('utf8'));
715
+ if (
716
+ parsed.name !== manifest.name ||
717
+ parsed.description !== manifest.description
718
+ ) {
719
+ throw new Error(
720
+ 'SKILL_PIN: SKILL.md does not match the manifest name and description.'
721
+ );
722
+ }
723
+ const policy = parsePolicy(policyBytes);
724
+ let imageBytes = null;
725
+ if (manifest.image) {
726
+ try {
727
+ imageBytes = await readFile(path.join(packDir, 'knowledge.v5'));
728
+ } catch (error) {
729
+ if (error && error.code === 'ENOENT') {
730
+ throw new Error('SKILL_PIN: knowledge.v5 is missing.');
731
+ }
732
+ throw error;
733
+ }
734
+ if (sha256(imageBytes) !== manifest.image.sha256) {
735
+ throw new Error(
736
+ 'SKILL_PIN: knowledge.v5 does not match its SHA-256 pin.'
737
+ );
738
+ }
739
+ const core = await loadCore();
740
+ let opened;
741
+ try {
742
+ opened = core.mountKnowledgeImageV5(imageBytes);
743
+ } catch {
744
+ throw new Error('SKILL_PIN: knowledge.v5 is not a V5 Knowledge Image.');
745
+ }
746
+ if (opened.stateRoot !== manifest.image.stateRoot) {
747
+ throw new Error('SKILL_PIN: knowledge.v5 does not match its state root.');
748
+ }
749
+ let policyRoot;
750
+ try {
751
+ policyRoot = core.knowledgePolicyRootV5(policy);
752
+ } catch {
753
+ throw new Error('SKILL_PIN: policy.json is not a V5 policy.');
754
+ }
755
+ if (opened.commit.policyRoot !== policyRoot) {
756
+ throw new Error(
757
+ 'SKILL_PIN: policy.json does not match the image policy root.'
758
+ );
759
+ }
760
+ } else {
761
+ try {
762
+ await lstat(path.join(packDir, 'knowledge.v5'));
763
+ throw new Error(
764
+ 'SKILL_PIN: knowledge.v5 is present but the manifest has no image pin.'
765
+ );
766
+ } catch (error) {
767
+ if (!(error && error.code === 'ENOENT')) throw error;
768
+ }
769
+ }
770
+ assertNoHost(manifestBytes.toString('utf8'));
771
+ return { manifest, manifestBytes, skillBytes, policyBytes, imageBytes };
772
+ }
773
+
774
+ function packSummary(built, out) {
775
+ return {
776
+ ok: true,
777
+ name: built.manifest.name,
778
+ out,
779
+ skillSha256: built.manifest.skillSha256,
780
+ image: built.manifest.image,
781
+ receiptId: built.receiptId,
782
+ };
783
+ }
784
+
785
+ function sameImage(left, right) {
786
+ if (left === null || right === null) return left === right;
787
+ return left.sha256 === right.sha256 && left.stateRoot === right.stateRoot;
788
+ }
789
+
790
+ function assertNoHost(text) {
791
+ if (ABSOLUTE_PATH.test(text) || text.includes('managed-skills')) {
792
+ throw new Error(
793
+ 'SKILL_PACK: the pack names a home directory or a host layout.'
794
+ );
795
+ }
796
+ }
797
+
798
+ function compareText(left, right) {
799
+ if (left < right) return -1;
800
+ if (left > right) return 1;
801
+ return 0;
802
+ }
803
+
804
+ async function rejectHostPath(target) {
805
+ const home = process.env.HOME;
806
+ if (home && (target === home || target.startsWith(`${home}${path.sep}`))) {
807
+ // A user can install into a directory under their home. The pack itself
808
+ // must not record that path. This check only rejects the home directory
809
+ // as the catalog or install root.
810
+ if (target === home) {
811
+ throw new Error(
812
+ 'SKILL_PACK: pass a directory inside the destination, not the home directory itself.'
813
+ );
814
+ }
815
+ }
816
+ }
817
+
818
+ function parsePolicy(policyBytes) {
819
+ try {
820
+ const policy = JSON.parse(policyBytes.toString('utf8'));
821
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
822
+ throw new Error('not an object');
823
+ }
824
+ return policy;
825
+ } catch {
826
+ throw new Error('SKILL_PIN: policy.json is not valid JSON.');
827
+ }
828
+ }
829
+
830
+ function sha256(bytes) {
831
+ return createHash('sha256').update(bytes).digest('hex');
832
+ }
833
+
834
+ async function loadCore() {
835
+ const candidates = [
836
+ path.resolve(__dirname, '../../core/dist/index.js'),
837
+ '@knolo/core',
838
+ ];
839
+ for (const candidate of candidates) {
840
+ try {
841
+ const href = candidate.startsWith('@')
842
+ ? candidate
843
+ : pathToFileURL(candidate).href;
844
+ const loaded = await import(href);
845
+ if (
846
+ loaded.createKnowledgeImageV5 &&
847
+ loaded.queryKnowledgeImageV5 &&
848
+ loaded.mountKnowledgeImageV5 &&
849
+ loaded.knowledgePolicyRootV5
850
+ ) {
851
+ return loaded;
852
+ }
853
+ } catch {
854
+ // Try the next candidate.
855
+ }
856
+ }
857
+ throw new Error('Could not load @knolo/core. Build packages/core first.');
858
+ }
859
+
860
+ const SKILL_ARCHIVE_FORMAT = 'knolo-skill-archive-v1';
861
+ const ARCHIVE_SECTIONS = ['manifest.json', 'SKILL.md', 'policy.json', 'knowledge.v5'];
862
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
863
+
864
+ function resolveSkillRegistry(requested, stored) {
865
+ const storedUrl = normalizeRegistryUrl(stored);
866
+ if (!requested) return storedUrl;
867
+ const requestedUrl = normalizeRegistryUrl(requested);
868
+ if (requestedUrl !== storedUrl) {
869
+ throw new Error(
870
+ `Stored credentials belong to ${storedUrl}; refusing to send them to ${requestedUrl}. Log in to the requested registry first.`
871
+ );
872
+ }
873
+ return requestedUrl;
874
+ }
875
+
876
+ export function encodeSkillArchive(pack) {
877
+ const sections = [
878
+ ['manifest.json', Buffer.from(pack.manifestBytes)],
879
+ ['SKILL.md', Buffer.from(pack.skillBytes)],
880
+ ['policy.json', Buffer.from(pack.policyBytes)],
881
+ ['knowledge.v5', Buffer.from(pack.imageBytes ?? Buffer.alloc(0))],
882
+ ];
883
+ const parts = [Buffer.from(`${SKILL_ARCHIVE_FORMAT}\n`)];
884
+ for (const [name, bytes] of sections) {
885
+ parts.push(Buffer.from(`${name} ${bytes.length}\n`));
886
+ if (bytes.length) parts.push(bytes);
887
+ }
888
+ return Buffer.concat(parts);
889
+ }
890
+
891
+ export function decodeSkillArchive(bytes) {
892
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
893
+ let offset = 0;
894
+ const header = readArchiveLine(buffer, offset);
895
+ offset = header.next;
896
+ if (header.line !== SKILL_ARCHIVE_FORMAT) {
897
+ throw new Error('SKILL_PIN: skill archive format is not a Knolo skill pack.');
898
+ }
899
+ const files = {};
900
+ for (const name of ARCHIVE_SECTIONS) {
901
+ const section = readArchiveSection(buffer, offset, name);
902
+ files[name] = section.body;
903
+ offset = section.next;
904
+ }
905
+ if (offset !== buffer.length) {
906
+ throw new Error('SKILL_PIN: skill archive has trailing bytes.');
907
+ }
908
+ const image = files['knowledge.v5'];
909
+ return {
910
+ manifestBytes: files['manifest.json'],
911
+ skillBytes: files['SKILL.md'],
912
+ policyBytes: files['policy.json'],
913
+ imageBytes: image.length ? image : null,
914
+ };
915
+ }
916
+
917
+ export async function publishSkillToRegistry(
918
+ packDir,
919
+ { fetchImpl = globalThis.fetch, registry, token }
920
+ ) {
921
+ const pack = await readVerifiedPack(packDir);
922
+ const archive = encodeSkillArchive(pack);
923
+ const archiveSha256 = sha256(archive);
924
+ const auth = { Authorization: `Bearer ${token}` };
925
+ await getJson(registryApiUrl(registry, ['account']), {
926
+ fetchImpl,
927
+ headers: auth,
928
+ });
929
+ const grant = await postJson(
930
+ registryApiUrl(registry, ['skills', 'uploads']),
931
+ { sha256: archiveSha256, contentLength: archive.length },
932
+ { fetchImpl, headers: auth }
933
+ );
934
+ const expected = `sha256/${archiveSha256}.knolo-skill`;
935
+ if (grant?.pathname !== expected) {
936
+ throw new Error(`SKILL_HUB: upload pathname is not locked to ${expected}.`);
937
+ }
938
+ let blobUrl;
939
+ if (grant.reused && typeof grant.artifact?.url === 'string') {
940
+ blobUrl = assertSkillBlobUrl(grant.artifact.url, archiveSha256);
941
+ } else {
942
+ const putUrl = assertSkillPutUrl(grant.upload?.url);
943
+ const uploaded = await putBytes(putUrl, archive, {
944
+ fetchImpl,
945
+ headers: blobPutHeaders(grant.upload?.headers, archive.length),
946
+ });
947
+ blobUrl = assertSkillBlobUrl(uploaded.url, archiveSha256);
948
+ }
949
+ let listed;
950
+ try {
951
+ listed = await postJson(
952
+ registryApiUrl(registry, ['skills']),
953
+ { archiveSha256, url: blobUrl, sizeBytes: archive.length },
954
+ { fetchImpl, headers: auth }
955
+ );
956
+ } catch (error) {
957
+ if (error?.code === 'pin_conflict' || error?.status === 409) {
958
+ throw new Error(
959
+ `SKILL_HUB: ${pack.manifest.name} is already in the catalog with a different pin.`
960
+ );
961
+ }
962
+ throw error;
963
+ }
964
+ if (typeof listed?.publisher !== 'string' || !listed.publisher) {
965
+ throw new Error('SKILL_HUB: the catalog did not name a publisher.');
966
+ }
967
+ return {
968
+ ok: true,
969
+ name: `${listed.publisher}/${pack.manifest.name}`,
970
+ hub: registry,
971
+ unchanged: Boolean(listed.unchanged),
972
+ skillSha256: pack.manifest.skillSha256,
973
+ image: pack.manifest.image,
974
+ };
975
+ }
976
+
977
+ export async function installSkillFromRegistry(
978
+ id,
979
+ destRoot,
980
+ { fetchImpl = globalThis.fetch, registry }
981
+ ) {
982
+ const { publisher, name } = parseSkillId(id);
983
+ const listed = await getJson(
984
+ registryApiUrl(registry, ['skills', publisher, name]),
985
+ { fetchImpl }
986
+ );
987
+ if (listed?.publisher !== publisher || listed?.slug !== name) {
988
+ throw new Error('SKILL_HUB: catalog id does not match the manifest.');
989
+ }
990
+ const url = assertSkillBlobUrl(listed.url, listed.archiveSha256);
991
+ if (typeof fetchImpl !== 'function') {
992
+ throw new Error('This Node runtime does not provide fetch.');
993
+ }
994
+ const response = await fetchImpl(url, { method: 'GET', redirect: 'follow' });
995
+ if (!response?.ok) {
996
+ throw new Error(
997
+ `SKILL_HUB: skill pack download failed with HTTP ${response?.status ?? 'error'}.`
998
+ );
999
+ }
1000
+ assertSkillBlobUrl(response.url || url, listed.archiveSha256);
1001
+ const bytes = Buffer.from(await response.arrayBuffer());
1002
+ if (sha256(bytes) !== listed.archiveSha256) {
1003
+ throw new Error(
1004
+ 'SKILL_PIN: downloaded skill pack does not match its SHA-256 pin.'
1005
+ );
1006
+ }
1007
+ const decoded = decodeSkillArchive(bytes);
1008
+ const parent = await mkdtemp(path.join(tmpdir(), 'knolo-skill-'));
1009
+ try {
1010
+ const temp = path.join(parent, name);
1011
+ await writePackFiles(temp, decoded);
1012
+ const pack = await readVerifiedPack(temp);
1013
+ if (
1014
+ pack.manifest.name !== name ||
1015
+ pack.manifest.skillSha256 !== listed.skillSha256 ||
1016
+ pack.manifest.policySha256 !== listed.policySha256 ||
1017
+ !sameImage(pack.manifest.image, listed.image ?? null)
1018
+ ) {
1019
+ throw new Error(
1020
+ 'SKILL_PIN: downloaded skill pack does not match the catalog pin.'
1021
+ );
1022
+ }
1023
+ const dest = path.resolve(destRoot, pack.manifest.name);
1024
+ await rejectHostPath(dest);
1025
+ try {
1026
+ await lstat(dest);
1027
+ throw new Error(`SKILL_PACK: refusing to overwrite ${dest}.`);
1028
+ } catch (error) {
1029
+ if (error && error.code !== 'ENOENT') throw error;
1030
+ }
1031
+ await writePackFiles(dest, pack);
1032
+ return { ok: true, name: `${publisher}/${name}`, dir: dest };
1033
+ } finally {
1034
+ await rm(parent, { recursive: true, force: true });
1035
+ }
1036
+ }
1037
+
1038
+ function parseSkillId(id) {
1039
+ if (typeof id !== 'string') {
1040
+ throw new Error('SKILL_HUB: pass publisher/name.');
1041
+ }
1042
+ const slash = id.indexOf('/');
1043
+ if (slash <= 0 || slash !== id.lastIndexOf('/')) {
1044
+ throw new Error('SKILL_HUB: pass publisher/name.');
1045
+ }
1046
+ const publisher = id.slice(0, slash);
1047
+ const name = id.slice(slash + 1);
1048
+ if (skillNameError(publisher) || skillNameError(name)) {
1049
+ throw new Error('SKILL_HUB: pass publisher/name.');
1050
+ }
1051
+ return { publisher, name };
1052
+ }
1053
+
1054
+ function assertSkillBlobUrl(value, digest) {
1055
+ if (typeof digest !== 'string' || !SHA256_HEX.test(digest)) {
1056
+ throw new Error('SKILL_HUB: archive pin is not a SHA-256.');
1057
+ }
1058
+ let url;
1059
+ try {
1060
+ url = new URL(value);
1061
+ } catch {
1062
+ throw new Error('SKILL_HUB: skill pack URL is invalid.');
1063
+ }
1064
+ if (
1065
+ url.protocol !== 'https:' ||
1066
+ url.username ||
1067
+ url.password ||
1068
+ url.search ||
1069
+ url.hash ||
1070
+ url.pathname.includes('..') ||
1071
+ url.pathname.includes('\\')
1072
+ ) {
1073
+ throw new Error('SKILL_HUB: skill pack URL must be a public HTTPS Blob URL.');
1074
+ }
1075
+ const host = url.hostname.toLowerCase();
1076
+ const blobHost =
1077
+ host === 'blob.vercel-storage.com' || host.endsWith('.blob.vercel-storage.com');
1078
+ const privateHost =
1079
+ host === 'private.blob.vercel-storage.com' ||
1080
+ host.endsWith('.private.blob.vercel-storage.com') ||
1081
+ host.includes('.private.');
1082
+ if (!blobHost || privateHost) {
1083
+ throw new Error('SKILL_HUB: skill pack URL must be a public Vercel Blob URL.');
1084
+ }
1085
+ const pathname = `sha256/${digest}.knolo-skill`;
1086
+ if (url.pathname !== `/${pathname}` && !url.pathname.endsWith(`/${pathname}`)) {
1087
+ throw new Error(`SKILL_HUB: skill pack URL is not locked to ${pathname}.`);
1088
+ }
1089
+ return url.toString();
1090
+ }
1091
+
1092
+ function assertSkillPutUrl(value) {
1093
+ if (typeof value !== 'string' || !value) {
1094
+ throw new Error('SKILL_HUB: upload URL is missing.');
1095
+ }
1096
+ let url;
1097
+ try {
1098
+ url = new URL(value);
1099
+ } catch {
1100
+ throw new Error('SKILL_HUB: upload URL is invalid.');
1101
+ }
1102
+ if (url.protocol !== 'https:') {
1103
+ throw new Error('SKILL_HUB: upload URL must use HTTPS.');
1104
+ }
1105
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
1106
+ if (
1107
+ host === 'localhost' ||
1108
+ host.endsWith('.localhost') ||
1109
+ host.endsWith('.local') ||
1110
+ host.includes('.private.') ||
1111
+ host === 'private.blob.vercel-storage.com' ||
1112
+ host.endsWith('.private.blob.vercel-storage.com') ||
1113
+ isPrivateSkillHost(host)
1114
+ ) {
1115
+ throw new Error('SKILL_HUB: refusing a private upload URL.');
1116
+ }
1117
+ return url.toString();
1118
+ }
1119
+
1120
+ function isPrivateSkillHost(host) {
1121
+ if (host === '0.0.0.0' || host === '127.0.0.1' || host === '::1') return true;
1122
+ const octets = host.split('.').map(Number);
1123
+ if (
1124
+ octets.length !== 4 ||
1125
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
1126
+ ) {
1127
+ return false;
1128
+ }
1129
+ const [first, second] = octets;
1130
+ return (
1131
+ first === 10 ||
1132
+ first === 127 ||
1133
+ (first === 169 && second === 254) ||
1134
+ (first === 172 && second >= 16 && second <= 31) ||
1135
+ (first === 192 && second === 168)
1136
+ );
1137
+ }
1138
+
1139
+ function blobPutHeaders(headers, length) {
1140
+ const next = { 'content-type': 'application/octet-stream' };
1141
+ if (headers && typeof headers === 'object') {
1142
+ for (const [key, value] of Object.entries(headers)) {
1143
+ if (key.toLowerCase() === 'authorization') continue;
1144
+ if (typeof value === 'string') next[key.toLowerCase()] = value;
1145
+ }
1146
+ }
1147
+ next['content-length'] = String(length);
1148
+ return next;
1149
+ }
1150
+
1151
+ function readArchiveLine(bytes, offset) {
1152
+ const newline = bytes.indexOf(0x0a, offset);
1153
+ if (newline < 0) throw new Error('SKILL_PIN: skill archive is truncated.');
1154
+ let line;
1155
+ try {
1156
+ line = new TextDecoder('utf8', { fatal: true }).decode(
1157
+ bytes.subarray(offset, newline)
1158
+ );
1159
+ } catch {
1160
+ throw new Error('SKILL_PIN: skill archive header is not text.');
1161
+ }
1162
+ if (line.includes('\u0000')) {
1163
+ throw new Error('SKILL_PIN: skill archive header is not text.');
1164
+ }
1165
+ return { line, next: newline + 1 };
1166
+ }
1167
+
1168
+ function readArchiveSection(bytes, offset, name) {
1169
+ const header = readArchiveLine(bytes, offset);
1170
+ const match = /^([A-Za-z0-9.]+) ([0-9]+)$/u.exec(header.line);
1171
+ if (!match || match[1] !== name || String(Number(match[2])) !== match[2]) {
1172
+ throw new Error(`SKILL_PIN: skill archive is missing ${name}.`);
1173
+ }
1174
+ const length = Number(match[2]);
1175
+ if (!Number.isSafeInteger(length) || header.next + length > bytes.length) {
1176
+ throw new Error('SKILL_PIN: skill archive is truncated.');
1177
+ }
1178
+ return {
1179
+ body: bytes.subarray(header.next, header.next + length),
1180
+ next: header.next + length,
1181
+ };
1182
+ }
1183
+
1184
+ export async function listSkills(flags = {}) {
1185
+ const print = flags.print ?? console.log;
1186
+ const registry = resolveRegistryUrl({
1187
+ value: flags.registry,
1188
+ env: flags.env ?? process.env,
1189
+ });
1190
+ const body = await getJson(String(registryApiUrl(registry, ['skills'])), {
1191
+ fetchImpl: flags.fetchImpl ?? globalThis.fetch,
1192
+ });
1193
+ if (!body || !Array.isArray(body.skills)) {
1194
+ throw new Error('SKILL_HUB: skill catalog response is invalid.');
1195
+ }
1196
+ const skills = body.skills.map(catalogSkill);
1197
+ if (flags.json) print(JSON.stringify(skills, null, 2));
1198
+ else if (skills.length === 0) print('no skills');
1199
+ else {
1200
+ for (const skill of skills) print(`${skill.publisher}/${skill.slug}`);
1201
+ }
1202
+ return { registry, skills };
1203
+ }
1204
+
1205
+ export async function searchSkills(query, flags = {}) {
1206
+ const print = flags.print ?? console.log;
1207
+ const text = typeof query === 'string' ? query.trim() : '';
1208
+ if (!text) throw new Error('SKILL_HUB: pass a search query.');
1209
+ const registry = resolveRegistryUrl({
1210
+ value: flags.registry,
1211
+ env: flags.env ?? process.env,
1212
+ });
1213
+ const body = await getJson(String(registryApiUrl(registry, ['skills'])), {
1214
+ fetchImpl: flags.fetchImpl ?? globalThis.fetch,
1215
+ });
1216
+ if (!body || !Array.isArray(body.skills)) {
1217
+ throw new Error('SKILL_HUB: skill catalog response is invalid.');
1218
+ }
1219
+ const terms = text.toLowerCase().split(/\s+/u);
1220
+ const skills = body.skills
1221
+ .filter((skill) => skillMatches(skill, terms))
1222
+ .map(catalogSkill);
1223
+ if (flags.json) print(JSON.stringify({ skills }, null, 2));
1224
+ else if (skills.length === 0) print('no skills');
1225
+ else {
1226
+ for (const skill of skills) {
1227
+ print(`${skill.publisher}/${skill.slug}`);
1228
+ print(` ${skill.description}`);
1229
+ }
1230
+ }
1231
+ return { registry, skills };
1232
+ }
1233
+
1234
+ export async function infoSkill(id, flags = {}) {
1235
+ const print = flags.print ?? console.log;
1236
+ const { publisher, name } = parseSkillId(id);
1237
+ const registry = resolveRegistryUrl({
1238
+ value: flags.registry,
1239
+ env: flags.env ?? process.env,
1240
+ });
1241
+ let body;
1242
+ try {
1243
+ body = await getJson(
1244
+ String(registryApiUrl(registry, ['skills', publisher, name])),
1245
+ { fetchImpl: flags.fetchImpl ?? globalThis.fetch }
1246
+ );
1247
+ } catch (error) {
1248
+ if (error?.status === 404) {
1249
+ throw new Error(`SKILL_HUB: ${publisher}/${name} was not found.`);
1250
+ }
1251
+ throw error;
1252
+ }
1253
+ if (flags.json) print(JSON.stringify(body, null, 2));
1254
+ else {
1255
+ print(`name ${publisher}/${name}`);
1256
+ print(`description ${body?.description ?? ''}`);
1257
+ print(`skill sha256 ${body?.skillSha256 ?? ''}`);
1258
+ print(`policy sha256 ${body?.policySha256 ?? ''}`);
1259
+ if (body?.image) {
1260
+ print(`image sha256 ${body.image.sha256 ?? ''}`);
1261
+ print(`state root ${body.image.stateRoot ?? ''}`);
1262
+ }
1263
+ print(`archive sha256 ${body?.archiveSha256 ?? ''}`);
1264
+ print(`size ${body?.sizeBytes ?? ''}`);
1265
+ print(`url ${body?.url ?? ''}`);
1266
+ }
1267
+ return { registry, skill: body };
1268
+ }
1269
+
1270
+ function skillMatches(skill, terms) {
1271
+ const haystack = [skill?.name, skill?.slug, skill?.publisher, skill?.description]
1272
+ .filter((value) => typeof value === 'string')
1273
+ .join('\n')
1274
+ .toLowerCase();
1275
+ return terms.every((term) => haystack.includes(term));
1276
+ }
1277
+
1278
+ function catalogSkill(skill) {
1279
+ return {
1280
+ name: skill?.name,
1281
+ publisher: skill?.publisher,
1282
+ slug: skill?.slug,
1283
+ description: skill?.description,
1284
+ skillSha256: skill?.skillSha256,
1285
+ policySha256: skill?.policySha256,
1286
+ image: skill?.image ?? null,
1287
+ };
1288
+ }
1289
+
1290
+ function parseArgs(argv) {
1291
+ const flags = {};
1292
+ const positionals = [];
1293
+ for (let index = 0; index < argv.length; index += 1) {
1294
+ const arg = argv[index];
1295
+ if (arg === '--json') flags.json = true;
1296
+ else if (arg === '--help' || arg === '-h') flags.help = true;
1297
+ else if (arg.startsWith('--')) {
1298
+ const key = arg.slice(2);
1299
+ const value = argv[index + 1];
1300
+ if (value === undefined || value.startsWith('--')) {
1301
+ throw new Error(`Missing value for --${key}`);
1302
+ }
1303
+ flags[key] = value;
1304
+ index += 1;
1305
+ } else positionals.push(arg);
1306
+ }
1307
+ return { positionals, flags };
1308
+ }