@knolo/cli 6.2.1 → 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/README.md CHANGED
@@ -12,8 +12,9 @@ the format is still draft and compressed object/event images require a VQF-aware
12
12
  reader.
13
13
 
14
14
  `knolo compose`, `knolo attest`, and `knolo verify-attestation` cite local
15
- Knowledge Images and write an attestation sidecar. `@knolo/cli` `6.2.0`
16
- depends on `@knolo/core` `^6.2.0`. They do not fetch Hub. Verification
15
+ Knowledge Images and write an attestation sidecar. This tree's `@knolo/cli`
16
+ package file is `6.2.2` and depends on `@knolo/core` `^6.2.0`. npm
17
+ `latest` is `6.2.1` until `6.2.2` is published. They do not fetch Hub. Verification
17
18
  recomputes `compositionRoot` and checks bundled excerpt bytes. A keyring key
18
19
  must fall inside `notBefore`, `notAfter`, and `revokedAt`. `used` means a
19
20
  query or evaluation ran. It does not mean `SATISFIED`, and it does not mean a
@@ -135,15 +136,22 @@ Successful installs are cached by SHA-256 and recorded in
135
136
  ## Skill packs
136
137
 
137
138
  `knolo skill` is separate from the Hub commands above. `knolo publish` still
138
- uploads a `.knolo` knowledge pack to `https://hub.knolo.dev`. A skill pack
139
- uses a directory you pass with `--hub`.
139
+ uploads a `.knolo` knowledge pack to `https://hub.knolo.dev`. `knolo skill
140
+ publish` sends a skill pack to that same hub. `--hub <dir>` remains a local
141
+ directory for an offline catalog.
140
142
 
141
143
  ```bash
142
144
  knolo skill init refund-policy
143
145
  knolo skill check refund-policy
144
146
  knolo skill pack refund-policy --out dist/refund-policy.knolo-skill
147
+ knolo skill publish dist/refund-policy.knolo-skill
148
+ knolo skill install acme/refund-policy
145
149
  knolo skill publish dist/refund-policy.knolo-skill --hub ./catalog
146
150
  knolo skill install refund-policy --hub ./catalog --dir ./skills
151
+ knolo skill search refund
152
+ knolo skill list
153
+ knolo skill info acme/refund-policy
154
+ knolo setup --root <dir> --agent <id> --auto-rebuild
147
155
  ```
148
156
 
149
157
  `init` writes `SKILL.md` and an empty `knowledge/` directory. `check` reads
@@ -156,15 +164,40 @@ does not mean a claim is proven.
156
164
  the image state root. It does not store a home directory or a computer
157
165
  layout. The same files produce the same pins.
158
166
 
159
- `publish` copies the pack into `--hub/<name>`. The same pin can be published
160
- again. A different pin is refused, and the catalog copy stays as it was.
161
- `install` checks the pin before it writes. A mismatch writes nothing. The
162
- catalog directory name must match the manifest. `SKILL.md` must match that
163
- name and description, and `policy.json` must match the image policy root.
164
- The files land only in `--dir/<name>`.
165
-
166
- The directory catalog stores packs. It does not run a model, and it does not
167
- answer questions. It is not `https://hub.knolo.dev`.
167
+ `publish` without `--hub` uses the stored `kno_…` token. The same pin can be
168
+ published again. A different pin is refused, and the catalog copy stays as
169
+ it was. `--hub <dir>` copies the pack into `<dir>/<name>` instead.
170
+ `install <publisher>/<name>` downloads one pack from the hub. It does not
171
+ read a stored token, and the download does not send one. `install` with
172
+ `--hub` or `--pack` reads a local directory. Either install checks the pin
173
+ before it writes. A mismatch writes nothing. The catalog id must match the
174
+ manifest. `SKILL.md` must match that name and description, and `policy.json`
175
+ must match the image policy root. The files land in `--dir/<name>`, or in
176
+ `~/.knolo/skills/<name>` when `--dir` is omitted. When that folder is a
177
+ skills root in `~/.knolo/box/box.config.json` and `knolo-box` is on `PATH`,
178
+ install runs one `knolo-box build`. A root written as `~/...` or
179
+ `$GROK_HOME/...` counts, and a skills scope left out of the file uses the
180
+ default `~/.grok` skill folders. A folder outside those roots is still
181
+ written, and install prints that it was not indexed. A failed build leaves
182
+ the installed files in place and prints that the install was not indexed.
183
+ `search`, `list`, and `info` read the public skill catalog. `list` prints
184
+ every `publisher/name`. `check` reads a source directory that contains
185
+ `knowledge/`. An installed pack contains `knowledge.v5` instead.
186
+
187
+ `knolo setup` runs `knolo-box init --tolerant` and then `knolo-box build`
188
+ when `knolo-box` is on `PATH`. It scans `~/.grok` when that directory
189
+ exists, plus every `--root`. It does not require `~/.grok`. It always
190
+ includes `~/.knolo/skills`. An existing `~/.knolo/box/box.config.json` is
191
+ kept and rebuilt. `--reset` replaces it. `--agent` sets the caller name.
192
+ `--auto-rebuild` turns on a rebuild at the next search. Those flags require
193
+ `--reset` when a config file is already there. Setup does not prompt,
194
+ install a pack, or call the hub. A missing `knolo-box` is one line, and
195
+ setup still exits 0. It prints the config path, `knolo skill install <publisher>/<name>`,
196
+ and `knolo-box mcp --config <path>`. The install path, auth, trust, and
197
+ version rules are in [Community install](../../docs/hub/COMMUNITY.md). This
198
+ page does not restate that contract. The hub stores packs. It does not run
199
+ a model, and it does not answer questions. `--hub` and `--registry` cannot
200
+ be combined.
168
201
 
169
202
  ---
170
203
 
@@ -326,7 +359,7 @@ This ensures:
326
359
  ## 🗺 Roadmap
327
360
 
328
361
  The active roadmap is maintained in [`../../docs/ROADMAP.md`](../../docs/ROADMAP.md).
329
- The current release is `6.2.0`. It preserves the V4 CLI, keeps V5 verification,
362
+ The published npm release is `6.2.1`. This package file is `6.2.2`. It preserves the V4 CLI, keeps V5 verification,
330
363
  migration, diagnostics, and read-only Studio management, and adds offline
331
364
  composition, attestation, and `knolo cortex`. Cortex verification does not
332
365
  mean an answer is true.
package/bin/knolo.mjs CHANGED
@@ -79,6 +79,7 @@ const SUBCOMMANDS = new Set([
79
79
  'cortex',
80
80
  'flight',
81
81
  'skill',
82
+ 'setup',
82
83
  ]);
83
84
  const ICP_SUBCOMMANDS = new Set([
84
85
  'init',
@@ -179,6 +180,7 @@ Commands:
179
180
  cortex Bounded evidence retrieval with a verifiable trace
180
181
  flight Pack a flight-events-1 document into a V5 Knowledge Image
181
182
  skill Create and share a Knolo skill pack
183
+ setup Prepare a local index and print the hub next steps
182
184
  icp Scaffold and operate an ICP-native knowledge canister
183
185
 
184
186
  Global options:
@@ -219,22 +221,39 @@ function printCommandHelp(command) {
219
221
  attest: 'Usage: knolo attest <create|verify> [options]\n\n knolo attest create --composition composition/composition.json --subject composition --statement mount|used|cited --out attestation.json [--sign-key key.json --principal <id> --key-id <id> --issued-at <ms> --expires-at <ms>]\n knolo attest verify attestation.json [--composition composition.json] [--public-key key.json] [--now <ms>] [--content-only]\n\nstatement used means a query or KAR evaluation ran. It does not mean the claim is true or false. Hub attested: true is unrelated. Signing uses the supplied times. The CLI does not read the clock.',
220
222
  cortex: 'Usage: knolo cortex <find|ask|explain|verify> [options]\n\nBounded evidence retrieval. The CLI does not call a model.\n\n knolo cortex find <image> --question "..." [--top-k 100] --out candidates.json\n knolo cortex ask <image> --question "..." [--graph file] [--plan plan.json] [--mode find_relevant] --out answer.cortex.json\n knolo cortex explain answer.cortex.json\n knolo cortex verify --image <image> --answer answer.cortex.json [--graph file]\n\nVerification recomputes the trace roots. It does not mean the answer is true. A KAR status of SATISFIED means evidence requirements were met. It does not mean a claim is proven.',
221
223
  flight: 'Usage: knolo flight ingest --events <events.json> --log <file> --out <image.knolo> [--json]\n\nPacks a flight-events-1 document into a V5 Knowledge Image. The command hashes --log and writes nothing when the hash or byte length differs. It does not decode the log. log.format is ulog, dataflash, or rosbag2. A later KAR status of SATISFIED means evidence requirements were met. It does not mean a flight was safe, legal, or compliant.',
222
- skill: `Usage: knolo skill <init|check|pack|publish|install>
224
+ setup: `Usage: knolo setup [--registry <url>] [--root <dir>] [--agent <id>] [--auto-rebuild] [--reset] [--skills <path>] [--managed-skills <path>] [--memory <path>] [--transcripts <path>]
225
+
226
+ Resolves the hub the same way as the other hub commands.
227
+ Scans ~/.grok when that directory exists, plus every --root.
228
+ Runs knolo-box init --tolerant, then knolo-box build --config.
229
+ Keeps an existing config and rebuilds it. Pass --reset to replace it.
230
+ Always includes ~/.knolo/skills as a skills root.
231
+ Pass --agent to set the caller name. Pass --auto-rebuild to rebuild on the next search.
232
+ Does not prompt. Does not install a pack or call the hub.
233
+ A missing knolo-box is printed, and setup still exits 0.
234
+ When more than one skill folder matches, pass --skills or --managed-skills.`,
235
+ skill: `Usage: knolo skill <init|check|pack|publish|install|search|info|list>
223
236
 
224
237
  knolo skill init <name> [--dir <parent>]
225
238
  knolo skill check [<dir>] [--query <text>] [--json]
226
239
  knolo skill pack [<dir>] [--out <dir>] [--json]
240
+ knolo skill publish <pack> [--registry <url>] [--json]
227
241
  knolo skill publish <pack> --hub <dir> [--json]
228
- knolo skill install <name> --hub <dir> --dir <path> [--json]
229
- knolo skill install --pack <pack> --dir <path> [--json]
242
+ knolo skill install <publisher>/<name> [--dir <path>] [--registry <url>] [--json]
243
+ knolo skill install <name> --hub <dir> [--dir <path>] [--json]
244
+ knolo skill install --pack <pack> [--dir <path>] [--json]
245
+ knolo skill search <query> [--registry <url>] [--json]
246
+ knolo skill info <publisher>/<name> [--registry <url>] [--json]
247
+ knolo skill list [--registry <url>] [--json]
230
248
 
231
249
  Creates a skill pack for the host agent. Knolo does not call a model.
232
250
  A receipt is an integrity check. It does not mean a claim is proven.
233
251
  SATISFIED means the evidence requirements were met.
234
- publish copies the pack into a directory catalog. install checks the pin
235
- and writes only into --dir. A pin mismatch writes nothing.
236
- The catalog stores packs and does not answer questions.
237
- It is not https://hub.knolo.dev.`,
252
+ publish sends the pack to https://hub.knolo.dev unless --hub names a
253
+ local directory. install checks the pin before it writes. It writes into
254
+ --dir, or into ~/.knolo/skills when --dir is omitted. A pin mismatch
255
+ writes nothing. list prints every publisher/name from the public catalog.
256
+ The hub stores packs and does not answer questions.`,
238
257
  'verify-attestation': 'Usage: knolo verify-attestation <attestation.json> [--composition composition.json] [--public-key key.json] [--keyring keyring.json] [--now <ms>] [--content-only]\n\nSame check as knolo attest verify. A bad signature exits 1 even when the content root matches. --content-only checks the content root and ignores the envelope. statement used is not a truth verdict. Hub attested: true is unrelated.',
239
258
  icp: 'Usage: knolo icp <command> [options]',
240
259
  'semantic:index':
@@ -1901,6 +1920,10 @@ async function main() {
1901
1920
  const { runSkillCli } = await import('./skill.mjs');
1902
1921
  return runSkillCli(commandArgs);
1903
1922
  }
1923
+ if (command === 'setup') {
1924
+ const { runKnoloSetup } = await import('./setup.mjs');
1925
+ return runKnoloSetup(commandArgs);
1926
+ }
1904
1927
  if (command === 'init') return await cmdInit();
1905
1928
  if (command === 'add') return await cmdAdd(commandArgs);
1906
1929
  if (command === 'search') return await runHubSearch(commandArgs);
package/bin/setup.mjs ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * knolo setup
3
+ *
4
+ * Writes a local box config and builds it when knolo-box is on PATH.
5
+ * It does not install a pack, call a hub write API, or open a network
6
+ * connection. A missing knolo-box is one line, and setup still exits 0.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { constants } from 'node:fs';
10
+ import { access, readFile, realpath, stat } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import path from 'node:path';
13
+ import { resolveRegistryUrl } from './registry/config.mjs';
14
+
15
+ export const SETUP_HELP = `Usage: knolo setup [--registry <url>] [--root <dir>] [--agent <id>] [--auto-rebuild] [--reset] [--skills <path>] [--managed-skills <path>] [--memory <path>] [--transcripts <path>]
16
+
17
+ Resolves the hub the same way as the other hub commands.
18
+ Scans ~/.grok when that directory exists, plus every --root.
19
+ Runs knolo-box init --tolerant, then knolo-box build --config.
20
+ Keeps an existing config and rebuilds it. Pass --reset to replace it.
21
+ Always includes ~/.knolo/skills as a skills root.
22
+ Pass --agent to set the caller name. Pass --auto-rebuild to rebuild on the next search.
23
+ Does not prompt. Does not install a pack or call the hub.
24
+ A missing knolo-box is printed, and setup still exits 0.
25
+ When more than one skill folder matches, pass --skills or --managed-skills.`;
26
+
27
+ export async function runKnoloSetup(
28
+ args,
29
+ {
30
+ env = process.env,
31
+ homeDir = homedir(),
32
+ spawnImpl = spawnBox,
33
+ print = console.log,
34
+ printError = console.error,
35
+ } = {}
36
+ ) {
37
+ const parsed = parseSetupArgs(args);
38
+ if (parsed.help) {
39
+ print(SETUP_HELP);
40
+ return { ok: true, help: true };
41
+ }
42
+ const registry = resolveRegistryUrl({ value: parsed.registry, env });
43
+ const box = await findOnPath('knolo-box', env.PATH);
44
+ const configPath = path.resolve(
45
+ path.join(homeDir, '.knolo', 'box', 'box.config.json')
46
+ );
47
+ const hubSkills = path.resolve(path.join(homeDir, '.knolo', 'skills'));
48
+ const grokHome = resolveGrokHome(env, homeDir);
49
+ const roots = [];
50
+ if (await directoryExists(grokHome)) roots.push(grokHome);
51
+ for (const root of parsed.roots) roots.push(root);
52
+
53
+ const existing = await fileExists(configPath);
54
+ if (existing && !parsed.reset && requestsNewConfig(parsed)) {
55
+ throw new Error(
56
+ `Config already exists: ${configPath}. Pass --reset to replace it.`
57
+ );
58
+ }
59
+ const replace = !existing || parsed.reset === true;
60
+
61
+ let built = false;
62
+ if (!box) {
63
+ printError('knolo-box is not on PATH. Skipping the local index.');
64
+ if (existing && !parsed.reset) printError('Kept the existing config.');
65
+ } else if (!replace) {
66
+ const buildStatus = await spawnImpl(
67
+ box,
68
+ ['build', '--config', configPath],
69
+ { env }
70
+ );
71
+ if (buildStatus !== 0) {
72
+ throw new Error(`knolo-box build exited with status ${buildStatus}.`);
73
+ }
74
+ built = true;
75
+ printError('Kept the existing config.');
76
+ } else {
77
+ const initArgs = [
78
+ 'init',
79
+ '--config',
80
+ configPath,
81
+ '--tolerant',
82
+ '--hub-skills',
83
+ hubSkills,
84
+ ];
85
+ for (const root of roots) initArgs.push('--root', root);
86
+ if (parsed.agent) initArgs.push('--agent', parsed.agent);
87
+ if (parsed.autoRebuild) initArgs.push('--auto-rebuild');
88
+ if (parsed.skills) initArgs.push('--skills', parsed.skills);
89
+ if (parsed.managedSkills) initArgs.push('--managed-skills', parsed.managedSkills);
90
+ if (parsed.memory) initArgs.push('--memory', parsed.memory);
91
+ if (parsed.transcripts) initArgs.push('--transcripts', parsed.transcripts);
92
+ const initStatus = await spawnImpl(box, initArgs, { env });
93
+ if (initStatus !== 0) {
94
+ throw new Error(`knolo-box init exited with status ${initStatus}.`);
95
+ }
96
+ const buildStatus = await spawnImpl(
97
+ box,
98
+ ['build', '--config', configPath],
99
+ { env }
100
+ );
101
+ if (buildStatus !== 0) {
102
+ throw new Error(`knolo-box build exited with status ${buildStatus}.`);
103
+ }
104
+ built = true;
105
+ if ((await skillRootsBesideHub(configPath, hubSkills)).length === 0) {
106
+ printError('No Grok skill folder was found.');
107
+ }
108
+ }
109
+
110
+ print(`Config: ${configPath}`);
111
+ print('First install: knolo skill install <publisher>/<name>');
112
+ print(`Connector: knolo-box mcp --config ${configPath}`);
113
+ print(`Publish needs a token from ${registry}/dashboard/tokens.`);
114
+ return { ok: true, registry, built, configPath };
115
+ }
116
+
117
+ export function resolveGrokHome(env, homeDir) {
118
+ const configured = typeof env.GROK_HOME === 'string' ? env.GROK_HOME.trim() : '';
119
+ const raw = configured || path.join(homeDir, '.grok');
120
+ if (raw === '~') return homeDir;
121
+ if (raw.startsWith('~/')) return path.resolve(path.join(homeDir, raw.slice(2)));
122
+ return path.resolve(raw);
123
+ }
124
+
125
+ export async function findOnPath(name, pathEnv) {
126
+ const dirs = String(pathEnv ?? '')
127
+ .split(path.delimiter)
128
+ .filter(Boolean);
129
+ for (const dir of dirs) {
130
+ const candidate = path.join(dir, name);
131
+ try {
132
+ await access(candidate, constants.X_OK);
133
+ return candidate;
134
+ } catch {
135
+ // Try the next directory.
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function requestsNewConfig(parsed) {
142
+ return (
143
+ parsed.roots.length > 0 ||
144
+ parsed.autoRebuild === true ||
145
+ Boolean(parsed.agent) ||
146
+ Boolean(parsed.skills) ||
147
+ Boolean(parsed.managedSkills) ||
148
+ Boolean(parsed.memory) ||
149
+ Boolean(parsed.transcripts)
150
+ );
151
+ }
152
+
153
+ function parseSetupArgs(args) {
154
+ const parsed = { roots: [], autoRebuild: false, reset: false };
155
+ for (let index = 0; index < args.length; index += 1) {
156
+ const arg = args[index];
157
+ if (arg === '--help' || arg === '-h') return { help: true };
158
+ if (arg === '--auto-rebuild') {
159
+ parsed.autoRebuild = true;
160
+ continue;
161
+ }
162
+ if (arg === '--reset') {
163
+ parsed.reset = true;
164
+ continue;
165
+ }
166
+ if (arg === '--root') {
167
+ parsed.roots.push(oneValue(args, index, arg));
168
+ index += 1;
169
+ continue;
170
+ }
171
+ const key = singleFlag(arg);
172
+ if (key) {
173
+ if (parsed[key] !== undefined) {
174
+ throw new Error(`${arg} accepts one value.`);
175
+ }
176
+ parsed[key] = oneValue(args, index, arg);
177
+ index += 1;
178
+ continue;
179
+ }
180
+ throw new Error(`Unknown argument for setup: ${arg}`);
181
+ }
182
+ return parsed;
183
+ }
184
+
185
+ function singleFlag(arg) {
186
+ if (arg === '--registry') return 'registry';
187
+ if (arg === '--agent') return 'agent';
188
+ if (arg === '--skills') return 'skills';
189
+ if (arg === '--managed-skills') return 'managedSkills';
190
+ if (arg === '--memory') return 'memory';
191
+ if (arg === '--transcripts') return 'transcripts';
192
+ return null;
193
+ }
194
+
195
+ function oneValue(args, index, arg) {
196
+ const value = args[index + 1];
197
+ if (value === undefined || value.startsWith('--')) {
198
+ throw new Error(`Missing value for ${arg}`);
199
+ }
200
+ return value;
201
+ }
202
+
203
+ async function fileExists(candidate) {
204
+ try {
205
+ await access(candidate, constants.F_OK);
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ async function directoryExists(candidate) {
213
+ try {
214
+ return (await stat(candidate)).isDirectory();
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ async function skillRootsBesideHub(configPath, hubSkills) {
221
+ let config;
222
+ try {
223
+ config = JSON.parse(await readFile(configPath, 'utf8'));
224
+ } catch {
225
+ return [];
226
+ }
227
+ const roots = config?.scopes?.skills?.roots;
228
+ if (!Array.isArray(roots)) return [];
229
+ const hub = await canonicalDir(hubSkills);
230
+ const others = [];
231
+ for (const root of roots) {
232
+ if (typeof root !== 'string' || root.length === 0) continue;
233
+ if ((await canonicalDir(root)) === hub) continue;
234
+ others.push(root);
235
+ }
236
+ return others;
237
+ }
238
+
239
+ async function canonicalDir(candidate) {
240
+ try {
241
+ return await realpath(candidate);
242
+ } catch {
243
+ return path.resolve(candidate);
244
+ }
245
+ }
246
+
247
+ function spawnBox(command, args, options = {}) {
248
+ return new Promise((resolve, reject) => {
249
+ const child = spawn(command, args, {
250
+ env: options.env,
251
+ cwd: options.cwd,
252
+ stdio: ['ignore', 'ignore', 'inherit'],
253
+ });
254
+ child.on('error', reject);
255
+ child.on('exit', (code, signal) => {
256
+ if (signal) reject(new Error(`knolo-box exited from ${signal}.`));
257
+ else resolve(code ?? 1);
258
+ });
259
+ });
260
+ }
package/bin/skill.mjs CHANGED
@@ -2,14 +2,24 @@
2
2
  * knolo skill
3
3
  *
4
4
  * Creates a Knolo skill pack: guidance plus an optional pinned V5 image.
5
- * The pack names no host and no home directory. A directory catalog stores
6
- * packs. It does not run a model and it does not answer questions.
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
7
  * SATISFIED means evidence requirements were met.
8
8
  */
9
+ import { spawn } from 'node:child_process';
9
10
  import { createHash } from 'node:crypto';
10
- import { lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
12
+ import { homedir, tmpdir } from 'node:os';
11
13
  import path from 'node:path';
12
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';
13
23
 
14
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
25
 
@@ -19,23 +29,32 @@ export const MAX_KNOWLEDGE_BYTES = 1024 * 1024;
19
29
  const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
30
  const EXTENSIONS = new Set(['.md', '.txt', '.json']);
21
31
 
22
- const USAGE = `Usage: knolo skill <init|check|pack|publish|install> [options]
32
+ const USAGE = `Usage: knolo skill <init|check|pack|publish|install|search|info|list> [options]
23
33
 
24
34
  knolo skill init <name> [--dir <parent>]
25
35
  knolo skill check [<dir>] [--query <text>] [--json]
26
36
  knolo skill pack [<dir>] [--out <dir>] [--json]
37
+ knolo skill publish <pack> [--registry <url>] [--json]
27
38
  knolo skill publish <pack> --hub <dir> [--json]
28
- knolo skill install <name> --hub <dir> --dir <path> [--json]
29
- knolo skill install --pack <pack> --dir <path> [--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]
30
45
 
31
46
  A skill is guidance for the host agent. Knolo does not call a model.
32
47
  check searches the skill's own knowledge and returns a receipt id.
33
48
  A receipt is an integrity check. It does not mean a claim is proven.
34
49
  SATISFIED means the evidence requirements were met.
35
- publish copies a pack into a directory catalog. install checks the pin
36
- before it writes, and it writes only into --dir.
37
- The catalog stores packs. It does not answer questions.
38
- This catalog is not https://hub.knolo.dev.`;
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.`;
39
58
 
40
59
  export async function runSkillCli(argv) {
41
60
  const { positionals, flags } = parseArgs(argv);
@@ -49,6 +68,9 @@ export async function runSkillCli(argv) {
49
68
  if (sub === 'pack') return packSkill(positionals[1], flags);
50
69
  if (sub === 'publish') return publishSkill(positionals[1], flags);
51
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);
52
74
  throw new Error(`Unknown skill command "${sub}".\n${USAGE}`);
53
75
  }
54
76
 
@@ -183,7 +205,27 @@ async function packSkill(dirArg, flags) {
183
205
  }
184
206
 
185
207
  async function publishSkill(packArg, flags) {
186
- if (!flags.hub) throw new Error('Missing --hub');
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) {
187
229
  const packDir = path.resolve(process.cwd(), packArg ?? '');
188
230
  if (!packArg) throw new Error('SKILL_HUB: pass the pack directory.');
189
231
  const pack = await readVerifiedPack(packDir);
@@ -223,9 +265,33 @@ async function publishSkill(packArg, flags) {
223
265
  else console.log(`published ${dest}`);
224
266
  }
225
267
 
226
- async function installSkill(nameArg, flags) {
227
- if (!flags.dir) throw new Error('Missing --dir');
228
- const destRoot = path.resolve(process.cwd(), flags.dir);
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
+ }
229
295
  let pack;
230
296
  if (flags.pack) {
231
297
  pack = await readVerifiedPack(path.resolve(process.cwd(), flags.pack));
@@ -253,8 +319,122 @@ async function installSkill(nameArg, flags) {
253
319
  }
254
320
  await writePackFiles(dest, pack);
255
321
  const summary = { ok: true, name: pack.manifest.name, dir: dest };
256
- if (flags.json) console.log(JSON.stringify(summary));
257
- else console.log(`installed ${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
+ });
258
438
  }
259
439
 
260
440
  async function loadSkillDir(dirArg) {
@@ -677,6 +857,436 @@ async function loadCore() {
677
857
  throw new Error('Could not load @knolo/core. Build packages/core first.');
678
858
  }
679
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
+
680
1290
  function parseArgs(argv) {
681
1291
  const flags = {};
682
1292
  const positionals = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knolo/cli",
3
- "version": "6.2.1",
3
+ "version": "6.2.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "knolo": "bin/knolo.mjs"
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "node -e \"console.log('cli is executable via bin/knolo.mjs')\"",
31
- "test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/kar.test.mjs test/kar-graph.test.mjs test/kar-produce.test.mjs test/hub.integration.test.mjs test/compose.test.mjs test/cortex.test.mjs test/flight.test.mjs test/skill.test.mjs",
31
+ "test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/kar.test.mjs test/kar-graph.test.mjs test/kar-produce.test.mjs test/hub.integration.test.mjs test/compose.test.mjs test/cortex.test.mjs test/flight.test.mjs test/skill.test.mjs test/setup.test.mjs",
32
32
  "test:hub": "node --test test/hub.integration.test.mjs"
33
33
  },
34
34
  "dependencies": {