@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 +47 -14
- package/bin/knolo.mjs +30 -7
- package/bin/setup.mjs +260 -0
- package/bin/skill.mjs +626 -16
- package/package.json +2 -2
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`
|
|
16
|
-
depends on `@knolo/core` `^6.2.0`.
|
|
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`.
|
|
139
|
-
|
|
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`
|
|
160
|
-
again. A different pin is refused, and the catalog copy stays as
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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> --
|
|
229
|
-
knolo skill install --
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
6
|
-
*
|
|
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> --
|
|
29
|
-
knolo skill install --
|
|
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
|
|
36
|
-
before it writes
|
|
37
|
-
|
|
38
|
-
|
|
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 (!
|
|
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
|
-
|
|
228
|
-
const
|
|
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
|
-
|
|
257
|
-
|
|
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.
|
|
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": {
|