@knolo/cli 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,41 @@ Or use via npx:
25
25
  npx knolo build
26
26
  ```
27
27
 
28
+ ## Hub Registry Discovery (V5.2)
29
+
30
+ The CLI can search the public Knolo Hub and inspect a published pack without
31
+ changing the local pack workflow:
32
+
33
+ ```bash
34
+ knolo search "refund policy"
35
+ knolo info acme/refund-policy
36
+ ```
37
+
38
+ The production registry is `https://hub.knolo.dev`. Use `--registry` for a
39
+ one-off override, or set `KNOLO_HUB_URL` for the current environment:
40
+
41
+ ```bash
42
+ knolo search docs --registry https://hub.knolo.dev
43
+ KNOLO_HUB_URL=http://localhost:3000 knolo search docs
44
+ ```
45
+
46
+ An explicit `--registry` value takes precedence over `KNOLO_HUB_URL`. In
47
+ development mode (`NODE_ENV=development`), the default is
48
+ `http://localhost:3000`; otherwise the default is `https://hub.knolo.dev`.
49
+
50
+ The current Hub surface is read-only discovery and verified installation. Local
51
+ `knolo add <name> <path>` continues to update a project’s local source
52
+ configuration. A Hub pack is installed only after its manifest, Blob bytes,
53
+ digest, size, and local Knowledge Image structure have all been verified:
54
+
55
+ ```bash
56
+ knolo add acme/refund-policy@1.2.0
57
+ ```
58
+
59
+ Successful installs are cached by SHA-256 and recorded in
60
+ `knolo.lock.json`. A yanked version or an existing conflicting pin requires
61
+ `--force`; digest and artifact validation cannot be bypassed.
62
+
28
63
  ---
29
64
 
30
65
  ## 🚀 Commands
package/bin/knolo.mjs CHANGED
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import process from 'node:process';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import { createRequire } from 'node:module';
23
+ import { isHubAddInvocation, runHubAdd, runHubInfo, runHubLogin, runHubLogout, runHubPublishStub, runHubSearch, runHubWhoami } from './registry/commands.mjs';
23
24
 
24
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26
  const require = createRequire(import.meta.url);
@@ -33,7 +34,7 @@ const DEFAULT_CONFIG = {
33
34
  };
34
35
  const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json']);
35
36
  const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.dfx', '.cargo-target']);
36
- const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
37
+ const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'search', 'info', 'login', 'whoami', 'logout', 'publish', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
37
38
  const ICP_SUBCOMMANDS = new Set(['init', 'build-pack', 'upload', 'query', 'health', 'info', 'clear']);
38
39
  const ICP_TEMPLATE_CANDIDATES = [
39
40
  path.resolve(__dirname, '../templates/icp-knowledge-canister'),
@@ -79,7 +80,13 @@ Usage:
79
80
 
80
81
  Commands:
81
82
  init Initialize knolo.config.json and starter docs
82
- add <name> <path> Add or update a source entry in config
83
+ add <name> <path> Add or update a local source entry, or install a Hub pack
84
+ search <query> Search the Knolo Hub registry
85
+ info <publisher>/<slug> Show a Hub registry pack
86
+ login Store a Hub token locally
87
+ whoami Show the stored Hub token prefix
88
+ logout Remove the stored Hub token
89
+ publish Publish support is waiting for Hub write APIs
83
90
  build Build a .knolo pack from configured sources
84
91
  query <question> Query a built pack and print top hits
85
92
  inspect <pack> Inspect pack format, sections, and manifest
@@ -101,7 +108,13 @@ Run "knolo <command> --help" for command details.`);
101
108
  function printCommandHelp(command) {
102
109
  const help = {
103
110
  init: 'Usage: knolo init',
104
- add: 'Usage: knolo add <name> <path>',
111
+ add: 'Usage: knolo add <name> <path> | <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]',
112
+ search: 'Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]',
113
+ info: 'Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]',
114
+ login: 'Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]',
115
+ whoami: 'Usage: knolo whoami',
116
+ logout: 'Usage: knolo logout',
117
+ publish: 'Usage: knolo publish <pack.knolo> (not available until Hub write APIs support CLI tokens)',
105
118
  build: 'Usage: knolo build',
106
119
  query: 'Usage: knolo query <question> [--pack <path>] [--k <number>] [--receipt <file>] [--json]',
107
120
  inspect: 'Usage: knolo inspect <pack.knolo>',
@@ -272,6 +285,14 @@ async function cmdInit() {
272
285
  }
273
286
 
274
287
  async function cmdAdd(args) {
288
+ if (isHubAddInvocation(args)) {
289
+ const core = await loadCore();
290
+ return await runHubAdd(args, { core });
291
+ }
292
+ return await cmdAddLocal(args);
293
+ }
294
+
295
+ async function cmdAddLocal(args) {
275
296
  const [name, sourcePath] = args;
276
297
  if (!name || !sourcePath) throw createError('Usage: knolo add <name> <path>');
277
298
 
@@ -1169,6 +1190,12 @@ async function main() {
1169
1190
  if (commandArgs.includes('--help') || commandArgs.includes('-h')) return printCommandHelp(command);
1170
1191
  if (command === 'init') return await cmdInit();
1171
1192
  if (command === 'add') return await cmdAdd(commandArgs);
1193
+ if (command === 'search') return await runHubSearch(commandArgs);
1194
+ if (command === 'info') return await runHubInfo(commandArgs);
1195
+ if (command === 'login') return await runHubLogin(commandArgs);
1196
+ if (command === 'whoami') return await runHubWhoami(commandArgs);
1197
+ if (command === 'logout') return await runHubLogout(commandArgs);
1198
+ if (command === 'publish') return runHubPublishStub(commandArgs);
1172
1199
 
1173
1200
  const core = await loadCore();
1174
1201
  if (command === 'build') return await cmdBuild(core);
@@ -0,0 +1,143 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { chmod, mkdir, readFile, rename, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+
7
+ export const MAX_ARTIFACT_BYTES = 250 * 1024 * 1024;
8
+
9
+ export async function downloadBlobToTemp(url, {
10
+ tempDir,
11
+ expectedSize = 0,
12
+ fetchImpl = globalThis.fetch,
13
+ } = {}) {
14
+ const blobUrl = new URL(url);
15
+ if (blobUrl.protocol !== 'https:') throw new Error('Blob URL must use HTTPS.');
16
+ if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
17
+ if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw new Error('Manifest sizeBytes is invalid.');
18
+ if (expectedSize > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
19
+
20
+ let response;
21
+ try {
22
+ response = await fetchImpl(blobUrl, { method: 'GET', redirect: 'follow' });
23
+ } catch (error) {
24
+ throw new Error(`Could not download artifact from ${blobUrl.origin}.`, { cause: error });
25
+ }
26
+
27
+ if (!response.ok) {
28
+ const detail = await safeResponseText(response);
29
+ throw new Error(`Artifact download failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`);
30
+ }
31
+ if (response.url) {
32
+ const finalUrl = new URL(response.url);
33
+ if (finalUrl.protocol !== 'https:') throw new Error('Blob redirect must remain HTTPS.');
34
+ }
35
+
36
+ const contentLength = readContentLength(response);
37
+ if (contentLength !== undefined && contentLength > MAX_ARTIFACT_BYTES) {
38
+ throw new Error('Artifact exceeds Hub’s 250 MB limit.');
39
+ }
40
+ if (expectedSize > 0 && contentLength !== undefined && contentLength !== expectedSize) {
41
+ throw new Error(`Artifact Content-Length ${contentLength} does not match manifest sizeBytes ${expectedSize}.`);
42
+ }
43
+
44
+ await mkdir(tempDir, { recursive: true });
45
+ const tempPath = path.join(tempDir, `.download-${randomUUID()}.tmp`);
46
+ const output = createWriteStream(tempPath, { flags: 'wx', mode: 0o600 });
47
+ const hash = createHash('sha256');
48
+ let sizeBytes = 0;
49
+
50
+ try {
51
+ if (response.body && typeof response.body.getReader === 'function') {
52
+ for await (const chunk of Readable.fromWeb(response.body)) {
53
+ const bytes = Buffer.from(chunk);
54
+ sizeBytes += bytes.length;
55
+ if (sizeBytes > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
56
+ hash.update(bytes);
57
+ await writeChunk(output, bytes);
58
+ }
59
+ } else if (typeof response.arrayBuffer === 'function') {
60
+ const bytes = Buffer.from(await response.arrayBuffer());
61
+ sizeBytes = bytes.length;
62
+ if (sizeBytes > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
63
+ hash.update(bytes);
64
+ await writeChunk(output, bytes);
65
+ } else {
66
+ throw new Error('Artifact response did not provide a readable body.');
67
+ }
68
+ await endStream(output);
69
+ } catch (error) {
70
+ output.destroy();
71
+ await unlink(tempPath).catch(() => {});
72
+ throw error;
73
+ }
74
+
75
+ if (expectedSize > 0 && sizeBytes !== expectedSize) {
76
+ await unlink(tempPath).catch(() => {});
77
+ throw new Error(`Artifact body length ${sizeBytes} does not match manifest sizeBytes ${expectedSize}.`);
78
+ }
79
+
80
+ await chmod(tempPath, 0o600);
81
+ return { tempPath, sizeBytes, sha256: hash.digest('hex'), contentLength };
82
+ }
83
+
84
+ export async function readArtifact(tempPath) {
85
+ return new Uint8Array(await readFile(tempPath));
86
+ }
87
+
88
+ export async function installCachedArtifact(tempPath, cachePath) {
89
+ await mkdir(path.dirname(cachePath), { recursive: true });
90
+ await chmod(tempPath, 0o644);
91
+ await rename(tempPath, cachePath);
92
+ await chmod(cachePath, 0o644);
93
+ return cachePath;
94
+ }
95
+
96
+ async function writeChunk(stream, chunk) {
97
+ if (stream.write(chunk)) return;
98
+ await new Promise((resolve, reject) => {
99
+ const onDrain = () => {
100
+ stream.off('error', onError);
101
+ resolve();
102
+ };
103
+ const onError = (error) => {
104
+ stream.off('drain', onDrain);
105
+ reject(error);
106
+ };
107
+ stream.once('drain', onDrain);
108
+ stream.once('error', onError);
109
+ });
110
+ }
111
+
112
+ async function endStream(stream) {
113
+ await new Promise((resolve, reject) => {
114
+ const onClose = () => {
115
+ stream.off('error', onError);
116
+ resolve();
117
+ };
118
+ const onError = (error) => {
119
+ stream.off('close', onClose);
120
+ reject(error);
121
+ };
122
+ stream.once('close', onClose);
123
+ stream.once('error', onError);
124
+ stream.end();
125
+ });
126
+ }
127
+
128
+ function readContentLength(response) {
129
+ const raw = response.headers?.get?.('content-length') ?? response.headers?.['content-length'] ?? response.headers?.['Content-Length'];
130
+ if (raw === undefined || raw === null || raw === '') return undefined;
131
+ const value = Number(raw);
132
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error('Artifact Content-Length is invalid.');
133
+ return value;
134
+ }
135
+
136
+ async function safeResponseText(response) {
137
+ try {
138
+ const text = await response.text();
139
+ return String(text || '').replace(/\s+/g, ' ').slice(0, 240);
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
@@ -0,0 +1,392 @@
1
+ import { copyFile, mkdir, rename, unlink } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { registryApiUrl, resolveRegistryUrl } from './config.mjs';
5
+ import { downloadBlobToTemp, installCachedArtifact, readArtifact } from './artifacts.mjs';
6
+ import { RegistryError } from './errors.mjs';
7
+ import { getJson } from './http.mjs';
8
+ import { cachePath, upsertLockfile, validateLockfileUpdate } from './lockfile.mjs';
9
+ import { validatePackManifest } from './manifest.mjs';
10
+ import { parsePackName, parsePackSpec } from './specs.mjs';
11
+ import { loadCredentials, promptForToken, removeCredentials, saveCredentials, validateToken } from './credentials.mjs';
12
+
13
+ const PACK_SHA256_PATTERN = /^[0-9a-f]{64}$/;
14
+
15
+ export async function runHubSearch(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
16
+ const { query, flags } = parseSearchArgs(args);
17
+ const registry = resolveRegistryUrl({ value: flags.registry, env });
18
+ const url = registryApiUrl(registry, ['packs'], {
19
+ q: query,
20
+ format: flags.format,
21
+ license: flags.license,
22
+ official: flags.official ? 'true' : undefined,
23
+ agents: flags.agents ? 'true' : undefined,
24
+ });
25
+ const body = await getJson(url, { fetchImpl });
26
+ validatePacksResponse(body);
27
+
28
+ if (flags.json) print(JSON.stringify(body, null, 2));
29
+ else print(renderSearchTable(body.packs));
30
+
31
+ return { registry, url: String(url), body };
32
+ }
33
+
34
+ export async function runHubInfo(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
35
+ const { positional, flags } = parseInfoArgs(args);
36
+ const pack = parsePackName(positional[0]);
37
+ const registry = resolveRegistryUrl({ value: flags.registry, env });
38
+ const url = registryApiUrl(registry, ['packs', pack.publisher, pack.slug]);
39
+ const body = await getJson(url, { fetchImpl });
40
+ validatePackInfoResponse(body);
41
+
42
+ if (flags.json) print(JSON.stringify(body, null, 2));
43
+ else print(renderPackInfo(body));
44
+
45
+ return { registry, url: String(url), body };
46
+ }
47
+
48
+ export async function runHubAdd(args, {
49
+ env = process.env,
50
+ fetchImpl = globalThis.fetch,
51
+ core,
52
+ cwd = process.cwd(),
53
+ homeDir,
54
+ print = console.log,
55
+ warn = console.error,
56
+ } = {}) {
57
+ const { spec, flags } = parseAddArgs(args);
58
+ if (!core) throw new Error('Could not load @knolo/core for Hub artifact validation.');
59
+ const registry = resolveRegistryUrl({ value: flags.registry, env });
60
+ const manifestUrl = registryApiUrl(registry, ['packs', spec.publisher, spec.slug, spec.version]);
61
+
62
+ let manifest;
63
+ let yanked = false;
64
+ try {
65
+ const body = await getJson(manifestUrl, { fetchImpl });
66
+ manifest = validatePackManifest(body, { expectedName: spec.name, requestedVersion: spec.version });
67
+ } catch (error) {
68
+ if (error instanceof RegistryError && (error.status === 404 || error.code === 'not_found')) {
69
+ throw new Error('pack version not found');
70
+ }
71
+ if (error instanceof RegistryError && (error.status === 410 || error.code === 'yanked')) {
72
+ manifest = validatePackManifest(error.body, { expectedName: spec.name, requestedVersion: spec.version });
73
+ yanked = true;
74
+ } else {
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ if (manifest.yanked) yanked = true;
80
+ if (yanked && !flags.force) throw new Error('version yanked; use --force to download yanked bytes');
81
+ if (yanked) warn(`warning: downloading yanked version ${manifest.name}@${manifest.version}`);
82
+ if (!manifest.url) throw new Error('artifact bytes are not stored yet');
83
+ if (!PACK_SHA256_PATTERN.test(manifest.sha256)) throw new Error('manifest sha256 is invalid');
84
+
85
+ const artifactHome = homeDir || homedir();
86
+ await validateLockfileUpdate({ cwd, registry, manifest, force: flags.force });
87
+ const finalCachePath = cachePath(artifactHome, manifest.sha256);
88
+ const tempDir = path.dirname(finalCachePath);
89
+ const download = await downloadBlobToTemp(manifest.url, {
90
+ tempDir,
91
+ expectedSize: manifest.sizeBytes,
92
+ fetchImpl,
93
+ });
94
+ let installed = false;
95
+ try {
96
+ if (download.sha256 !== manifest.sha256) {
97
+ throw new Error(`artifact sha256 mismatch: expected ${manifest.sha256}, got ${download.sha256}`);
98
+ }
99
+ const bytes = await readArtifact(download.tempPath);
100
+ const validation = await validateKnowledgeImage(core, bytes);
101
+ if (manifest.format && String(manifest.format).toUpperCase() !== validation.format) {
102
+ throw new Error(`artifact format mismatch: manifest=${manifest.format}, bytes=${validation.format}`);
103
+ }
104
+ if (manifest.stateRoot !== undefined && manifest.stateRoot !== validation.stateRoot) {
105
+ throw new Error(`artifact stateRoot mismatch: expected ${manifest.stateRoot}, got ${validation.stateRoot || 'none'}`);
106
+ }
107
+ const cache = await installCachedArtifact(download.tempPath, finalCachePath);
108
+ installed = true;
109
+ const lock = await upsertLockfile({ cwd, registry, manifest, force: flags.force });
110
+ let outputPath = cache;
111
+ if (flags.out) {
112
+ outputPath = await copyArtifact(cache, flags.out, cwd);
113
+ }
114
+ const result = { ...manifest, path: outputPath, lockfile: lock.path };
115
+ if (flags.json) print(JSON.stringify(result, null, 2));
116
+ else {
117
+ print(`added ${manifest.name}@${manifest.version}`);
118
+ print(`sha256 ${manifest.sha256}`);
119
+ print(`url ${manifest.url}`);
120
+ print(`path ${outputPath}`);
121
+ }
122
+ return result;
123
+ } finally {
124
+ if (!installed) await unlinkIfPresent(download.tempPath);
125
+ }
126
+ }
127
+
128
+ export async function runHubLogin(args, {
129
+ env = process.env,
130
+ homeDir,
131
+ input = process.stdin,
132
+ output = process.stderr,
133
+ print = console.log,
134
+ } = {}) {
135
+ const { flags } = parseLoginArgs(args);
136
+ const registry = resolveRegistryUrl({ value: flags.registry, env });
137
+ print(`Create a token at ${registry}/dashboard/tokens (Sign in with GitHub).`);
138
+ const token = (flags.token ?? await promptForToken({ input, output, forceStdin: flags.stdin })).trim();
139
+ validateToken(token);
140
+ const saved = await saveCredentials({ registry, token, env, homeDir });
141
+ print(`logged in to ${saved.registry}`);
142
+ print(`token ${saved.prefix}`);
143
+ return saved;
144
+ }
145
+
146
+ export async function runHubWhoami(args, { env = process.env, homeDir, print = console.log } = {}) {
147
+ if (args.length) throw new Error('Usage: knolo whoami');
148
+ const credentials = await loadCredentials({ env, homeDir });
149
+ print(`registry ${credentials.registry}`);
150
+ print(`token ${credentials.prefix}`);
151
+ return { registry: credentials.registry, prefix: credentials.prefix };
152
+ }
153
+
154
+ export async function runHubLogout(args, { env = process.env, homeDir, print = console.log } = {}) {
155
+ if (args.length) throw new Error('Usage: knolo logout');
156
+ const result = await removeCredentials({ env, homeDir });
157
+ print(result.removed ? 'logged out' : 'not logged in');
158
+ return result;
159
+ }
160
+
161
+ export function runHubPublishStub() {
162
+ throw new Error('Hub write APIs do not accept CLI tokens yet');
163
+ }
164
+
165
+ export function parseAddArgs(args) {
166
+ const positional = [];
167
+ const flags = {};
168
+ for (let i = 0; i < args.length; i++) {
169
+ const arg = args[i];
170
+ if (arg === '--force' || arg === '--json') {
171
+ flags[arg.slice(2)] = true;
172
+ continue;
173
+ }
174
+ if (['--out', '--registry'].includes(arg)) {
175
+ const value = args[++i];
176
+ if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
177
+ flags[arg.slice(2)] = value;
178
+ continue;
179
+ }
180
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for add: ${arg}`);
181
+ positional.push(arg);
182
+ }
183
+ if (positional.length !== 1) throw new Error('Usage: knolo add <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]');
184
+ return { spec: parsePackSpec(positional[0]), flags };
185
+ }
186
+
187
+ export function parseLoginArgs(args) {
188
+ const flags = {};
189
+ for (let i = 0; i < args.length; i++) {
190
+ const arg = args[i];
191
+ if (arg === '--stdin') {
192
+ flags.stdin = true;
193
+ continue;
194
+ }
195
+ if (arg === '--token' || arg === '--registry') {
196
+ const value = args[++i];
197
+ if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
198
+ flags[arg.slice(2)] = value;
199
+ continue;
200
+ }
201
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for login: ${arg}`);
202
+ throw new Error('Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]');
203
+ }
204
+ return { flags };
205
+ }
206
+
207
+ export function isHubAddInvocation(args) {
208
+ if (!args[0] || args[0].startsWith('--')) return false;
209
+ const positional = [];
210
+ for (let i = 0; i < args.length; i++) {
211
+ const arg = args[i];
212
+ if (['--out', '--registry'].includes(arg)) {
213
+ i += 1;
214
+ continue;
215
+ }
216
+ if (arg === '--force' || arg === '--json') continue;
217
+ if (arg.startsWith('--')) return false;
218
+ positional.push(arg);
219
+ }
220
+ if (positional.length !== 1) return false;
221
+ try {
222
+ parsePackSpec(positional[0]);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ export function parseSearchArgs(args) {
230
+ const positional = [];
231
+ const flags = {};
232
+ for (let i = 0; i < args.length; i++) {
233
+ const arg = args[i];
234
+ if (arg === '--json' || arg === '--official' || arg === '--agents') {
235
+ flags[arg.slice(2)] = true;
236
+ continue;
237
+ }
238
+ if (['--format', '--license', '--registry'].includes(arg)) {
239
+ const value = args[++i];
240
+ if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
241
+ flags[arg.slice(2)] = value;
242
+ continue;
243
+ }
244
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for search: ${arg}`);
245
+ positional.push(arg);
246
+ }
247
+
248
+ const query = positional.join(' ').trim();
249
+ if (!query) throw new Error('Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]');
250
+ if (flags.format && !['V4', 'V5'].includes(String(flags.format).toUpperCase())) {
251
+ throw new Error('--format must be V4 or V5.');
252
+ }
253
+ if (flags.format) flags.format = String(flags.format).toUpperCase();
254
+ return { query, flags };
255
+ }
256
+
257
+ export function parseInfoArgs(args) {
258
+ const positional = [];
259
+ const flags = {};
260
+ for (let i = 0; i < args.length; i++) {
261
+ const arg = args[i];
262
+ if (arg === '--json') {
263
+ flags.json = true;
264
+ continue;
265
+ }
266
+ if (arg === '--registry') {
267
+ const value = args[++i];
268
+ if (value === undefined || value.startsWith('--')) throw new Error('Missing value for --registry');
269
+ flags.registry = value;
270
+ continue;
271
+ }
272
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for info: ${arg}`);
273
+ positional.push(arg);
274
+ }
275
+ if (positional.length !== 1) throw new Error('Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]');
276
+ return { positional, flags };
277
+ }
278
+
279
+ function validatePacksResponse(body) {
280
+ if (!body || typeof body !== 'object' || !Array.isArray(body.packs)) {
281
+ throw new Error('Registry returned an invalid packs response.');
282
+ }
283
+ }
284
+
285
+ function validatePackInfoResponse(body) {
286
+ if (!body || typeof body !== 'object' || typeof body.name !== 'string' || !body.pack || typeof body.pack !== 'object') {
287
+ throw new Error('Registry returned an invalid pack response.');
288
+ }
289
+ }
290
+
291
+ async function validateKnowledgeImage(core, bytes) {
292
+ if (core.isKnowledgeImageV5?.(bytes)) {
293
+ try {
294
+ const verification = core.verifyKnowledgeImageV5(bytes);
295
+ return { format: 'V5', stateRoot: verification.stateRoot };
296
+ } catch (error) {
297
+ throw new Error(`invalid V5 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
298
+ }
299
+ }
300
+
301
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
302
+ if (core.isPackV4?.(buffer)) {
303
+ try {
304
+ await core.mountPack({ src: bytes });
305
+ return { format: 'V4' };
306
+ } catch (error) {
307
+ throw new Error(`invalid V4 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
308
+ }
309
+ }
310
+ throw new Error('not a Knowledge Image');
311
+ }
312
+
313
+ async function copyArtifact(sourcePath, outputPath, cwd) {
314
+ const targetPath = path.resolve(cwd, outputPath);
315
+ const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
316
+ await mkdir(path.dirname(targetPath), { recursive: true });
317
+ try {
318
+ await copyFile(sourcePath, tempPath);
319
+ await rename(tempPath, targetPath);
320
+ } finally {
321
+ await unlinkIfPresent(tempPath);
322
+ }
323
+ return targetPath;
324
+ }
325
+
326
+ async function unlinkIfPresent(filePath) {
327
+ try {
328
+ await unlink(filePath);
329
+ } catch (error) {
330
+ if (error?.code !== 'ENOENT') throw error;
331
+ }
332
+ }
333
+
334
+ export function renderSearchTable(packs) {
335
+ if (!packs.length) return 'No packs found.';
336
+
337
+ const rows = packs.map((pack) => [
338
+ pack.id || pack.name || `${pack.publisher || ''}/${pack.slug || ''}`,
339
+ pack.version ?? '',
340
+ pack.format ?? '',
341
+ pack.license ?? '',
342
+ pack.pulls ?? 0,
343
+ pack.description ?? '',
344
+ ].map((value) => String(value)));
345
+ const widths = [24, 9, 6, 11, 8];
346
+ const header = ['NAME', 'VERSION', 'FORMAT', 'LICENSE', 'PULLS', 'DESCRIPTION'];
347
+ const formatRow = (row) => [
348
+ pad(truncate(row[0], widths[0]), widths[0]),
349
+ pad(truncate(row[1], widths[1]), widths[1]),
350
+ pad(truncate(row[2], widths[2]), widths[2]),
351
+ pad(truncate(row[3], widths[3]), widths[3]),
352
+ pad(truncate(row[4], widths[4]), widths[4]),
353
+ truncate(row[5], 72),
354
+ ].join(' ').trimEnd();
355
+
356
+ return [formatRow(header), ...rows.map(formatRow)].join('\n');
357
+ }
358
+
359
+ export function renderPackInfo(body) {
360
+ const pack = body.pack || {};
361
+ const latest = body.latest || {};
362
+ const lines = [
363
+ `name ${body.name}`,
364
+ `description ${body.description || pack.description || ''}`,
365
+ `publisher ${body.publisher || pack.publisher || ''}`,
366
+ `slug ${body.slug || pack.slug || ''}`,
367
+ `format ${pack.format || ''}`,
368
+ `version ${pack.version || latest.version || ''}`,
369
+ `license ${pack.license || latest.license || ''}`,
370
+ `sizeBytes ${pack.sizeBytes ?? latest.sizeBytes ?? 0}`,
371
+ `docs ${pack.docs ?? 0}`,
372
+ `blocks ${pack.blocks ?? 0}`,
373
+ `namespaces ${Array.isArray(pack.namespaces) ? pack.namespaces.join(', ') : ''}`,
374
+ `pulls ${pack.pulls ?? 0}`,
375
+ `stars ${pack.stars ?? 0}`,
376
+ `topics ${Array.isArray(pack.topics) ? pack.topics.join(', ') : ''}`,
377
+ `latest ${latest.version || ''}`,
378
+ `sha256 ${latest.sha256 || pack.sha256 || ''}`,
379
+ `url ${latest.url || pack.blobUrl || ''}`,
380
+ ];
381
+ return lines.join('\n');
382
+ }
383
+
384
+ function truncate(value, width) {
385
+ if (value.length <= width) return value;
386
+ if (width <= 1) return '…'.slice(0, width);
387
+ return `${value.slice(0, width - 1)}…`;
388
+ }
389
+
390
+ function pad(value, width) {
391
+ return value.padEnd(width, ' ');
392
+ }
@@ -0,0 +1,46 @@
1
+ export const DEFAULT_PRODUCTION_REGISTRY = 'https://hub.knolo.dev';
2
+ export const DEFAULT_DEVELOPMENT_REGISTRY = 'http://localhost:3000';
3
+
4
+ export function resolveRegistryUrl({ value, env = process.env } = {}) {
5
+ const configured = value || env.KNOLO_HUB_URL;
6
+ if (configured) return normalizeRegistryUrl(configured);
7
+
8
+ const development = env.NODE_ENV === 'development' || env.KNOLO_ENV === 'development';
9
+ return normalizeRegistryUrl(development ? DEFAULT_DEVELOPMENT_REGISTRY : DEFAULT_PRODUCTION_REGISTRY);
10
+ }
11
+
12
+ export function normalizeRegistryUrl(value) {
13
+ if (typeof value !== 'string' || !value.trim()) {
14
+ throw new Error('Registry URL must be a non-empty HTTP(S) URL.');
15
+ }
16
+
17
+ let url;
18
+ try {
19
+ url = new URL(value.trim());
20
+ } catch {
21
+ throw new Error(`Invalid registry URL: ${value}`);
22
+ }
23
+
24
+ if (!['http:', 'https:'].includes(url.protocol)) {
25
+ throw new Error(`Registry URL must use http or https: ${value}`);
26
+ }
27
+ if (url.username || url.password || url.search || url.hash) {
28
+ throw new Error('Registry URL must be an origin without credentials, query parameters, or fragments.');
29
+ }
30
+ if (url.pathname !== '/' && url.pathname !== '') {
31
+ throw new Error('Registry URL must be an origin without a path.');
32
+ }
33
+
34
+ return url.origin;
35
+ }
36
+
37
+ export function registryApiUrl(registry, segments, searchParams) {
38
+ const url = new URL(normalizeRegistryUrl(registry));
39
+ url.pathname = `/api/v1/${segments.map((segment) => encodeURIComponent(String(segment))).join('/')}`;
40
+ if (searchParams) {
41
+ for (const [key, value] of Object.entries(searchParams)) {
42
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
43
+ }
44
+ }
45
+ return url;
46
+ }
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+
7
+ const TOKEN_PATTERN = /^kno_[A-Za-z0-9_-]+$/;
8
+
9
+ export function credentialsPath({ env = process.env, homeDir = homedir() } = {}) {
10
+ const configHome = env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(homeDir, '.config');
11
+ return path.join(configHome, 'knolo', 'credentials.json');
12
+ }
13
+
14
+ export async function saveCredentials({ registry, token, env = process.env, homeDir = homedir() } = {}) {
15
+ const normalizedToken = typeof token === 'string' ? token.trim() : token;
16
+ validateToken(normalizedToken);
17
+ if (typeof registry !== 'string' || !registry) throw new Error('Credential registry is required.');
18
+ const targetPath = credentialsPath({ env, homeDir });
19
+ const directory = path.dirname(targetPath);
20
+ await mkdir(directory, { recursive: true, mode: 0o700 });
21
+ await chmod(directory, 0o700);
22
+
23
+ const value = {
24
+ registry,
25
+ token: normalizedToken,
26
+ prefix: normalizedToken.slice(0, 12),
27
+ };
28
+ const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`;
29
+ try {
30
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
31
+ await chmod(tempPath, 0o600);
32
+ await rename(tempPath, targetPath);
33
+ await chmod(targetPath, 0o600);
34
+ } finally {
35
+ await unlink(tempPath).catch(() => {});
36
+ }
37
+ return { path: targetPath, registry, prefix: value.prefix };
38
+ }
39
+
40
+ export async function loadCredentials({ env = process.env, homeDir = homedir() } = {}) {
41
+ const filePath = credentialsPath({ env, homeDir });
42
+ let text;
43
+ try {
44
+ text = await readFile(filePath, 'utf8');
45
+ } catch (error) {
46
+ if (error?.code === 'ENOENT') throw new Error('Not logged in. Run "knolo login" first.');
47
+ throw error;
48
+ }
49
+
50
+ let value;
51
+ try {
52
+ value = JSON.parse(text);
53
+ } catch {
54
+ throw new Error('Invalid JSON in knolo credentials.');
55
+ }
56
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Knolo credentials must be a JSON object.');
57
+ if (typeof value.registry !== 'string' || !value.registry) throw new Error('Knolo credentials are missing registry.');
58
+ validateToken(value.token);
59
+ if (typeof value.prefix !== 'string' || value.prefix !== value.token.slice(0, 12)) throw new Error('Knolo credentials have an invalid token prefix.');
60
+ return { path: filePath, registry: value.registry, token: value.token, prefix: value.prefix };
61
+ }
62
+
63
+ export async function removeCredentials({ env = process.env, homeDir = homedir() } = {}) {
64
+ const filePath = credentialsPath({ env, homeDir });
65
+ try {
66
+ await unlink(filePath);
67
+ return { path: filePath, removed: true };
68
+ } catch (error) {
69
+ if (error?.code === 'ENOENT') return { path: filePath, removed: false };
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ export async function promptForToken({ input = process.stdin, output = process.stderr, forceStdin = false } = {}) {
75
+ if (forceStdin || !input.isTTY) {
76
+ const chunks = [];
77
+ for await (const chunk of input) chunks.push(Buffer.from(chunk));
78
+ return Buffer.concat(chunks).toString('utf8').trim();
79
+ }
80
+
81
+ const readline = createInterface({ input, output });
82
+ try {
83
+ return (await readline.question('Hub token (kno_…): ')).trim();
84
+ } finally {
85
+ readline.close();
86
+ }
87
+ }
88
+
89
+ export function validateToken(token) {
90
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token.trim())) {
91
+ throw new Error('Token must start with kno_ and contain only base64url characters.');
92
+ }
93
+ }
@@ -0,0 +1,28 @@
1
+ export class RegistryError extends Error {
2
+ constructor(message, { status, code, url, body, cause } = {}) {
3
+ super(message, cause ? { cause } : undefined);
4
+ this.name = 'RegistryError';
5
+ this.status = status;
6
+ this.code = code;
7
+ this.url = url;
8
+ this.body = body;
9
+ }
10
+ }
11
+
12
+ export class RegistryNetworkError extends Error {
13
+ constructor(message, { url, cause } = {}) {
14
+ super(message, cause ? { cause } : undefined);
15
+ this.name = 'RegistryNetworkError';
16
+ this.url = url;
17
+ }
18
+ }
19
+
20
+ export function registryErrorMessage({ status, code, body, url }) {
21
+ if (typeof body?.error === 'string' && body.error.trim()) return body.error.trim();
22
+ if (status === 401 || code === 'unauthenticated') return 'Registry authentication is required.';
23
+ if (status === 404 || code === 'not_found') return 'Registry resource not found.';
24
+ if (status === 410 || code === 'yanked') return 'Registry version is yanked.';
25
+ if (status === 413 || code === 'too_large') return 'Registry artifact is too large.';
26
+ if (status === 503 || code === 'unconfigured') return 'Registry is not configured.';
27
+ return `Registry request failed${status ? ` with HTTP ${status}` : ''}${url ? `: ${url}` : '.'}`;
28
+ }
@@ -0,0 +1,58 @@
1
+ import { RegistryError, RegistryNetworkError, registryErrorMessage } from './errors.mjs';
2
+
3
+ export async function getJson(url, { fetchImpl = globalThis.fetch, headers = {} } = {}) {
4
+ if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
5
+
6
+ let response;
7
+ try {
8
+ response = await fetchImpl(url, {
9
+ method: 'GET',
10
+ redirect: 'follow',
11
+ headers: { accept: 'application/json', ...headers },
12
+ });
13
+ } catch (error) {
14
+ throw new RegistryNetworkError(`Could not reach registry at ${new URL(url).origin}.`, { url: String(url), cause: error });
15
+ }
16
+
17
+ const text = await readResponseText(response, url);
18
+ let body;
19
+ if (text) {
20
+ try {
21
+ body = JSON.parse(text);
22
+ } catch {
23
+ if (!response.ok) {
24
+ throw new RegistryError(`Registry returned HTTP ${response.status} with a non-JSON response.`, {
25
+ status: response.status,
26
+ url: String(url),
27
+ body: text.slice(0, 240),
28
+ });
29
+ }
30
+ throw new RegistryError('Registry returned invalid JSON.', {
31
+ status: response.status,
32
+ code: 'invalid_response',
33
+ url: String(url),
34
+ body: text.slice(0, 240),
35
+ });
36
+ }
37
+ }
38
+
39
+ if (!response.ok) {
40
+ const code = typeof body?.code === 'string' ? body.code : undefined;
41
+ throw new RegistryError(registryErrorMessage({ status: response.status, code, body, url: String(url) }), {
42
+ status: response.status,
43
+ code,
44
+ url: String(url),
45
+ body,
46
+ });
47
+ }
48
+
49
+ return body;
50
+ }
51
+
52
+ async function readResponseText(response, url) {
53
+ try {
54
+ return await response.text();
55
+ } catch (error) {
56
+ throw new RegistryNetworkError(`Could not read registry response from ${new URL(url).origin}.`, { url: String(url), cause: error });
57
+ }
58
+ }
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const LOCKFILE_NAME = 'knolo.lock.json';
6
+
7
+ export function lockfilePath(cwd = process.cwd()) {
8
+ return path.resolve(cwd, LOCKFILE_NAME);
9
+ }
10
+
11
+ export function cachePath(homeDir, sha256) {
12
+ return path.join(homeDir, '.knolo', 'cache', 'sha256', `${sha256}.knolo`);
13
+ }
14
+
15
+ export async function validateLockfileUpdate({ cwd = process.cwd(), registry, manifest, force = false } = {}) {
16
+ const targetPath = lockfilePath(cwd);
17
+ const current = await readLockfile(targetPath);
18
+ if (current.registry && current.registry !== registry && !force) {
19
+ throw new Error(`Lockfile registry is ${current.registry}; refusing to mix registries without --force.`);
20
+ }
21
+
22
+ const packs = current.packs && typeof current.packs === 'object' && !Array.isArray(current.packs)
23
+ ? current.packs
24
+ : {};
25
+ const existing = packs[manifest.name];
26
+ if (existing?.sha256 && existing.sha256 !== manifest.sha256 && !force) {
27
+ throw new Error(`Lockfile already pins ${manifest.name} to a different digest; use --force to replace it.`);
28
+ }
29
+
30
+ const entry = {
31
+ version: manifest.version,
32
+ sha256: manifest.sha256,
33
+ ...(manifest.stateRoot !== undefined ? { stateRoot: manifest.stateRoot } : {}),
34
+ ...(manifest.license !== undefined ? { license: manifest.license } : {}),
35
+ };
36
+ return { targetPath, current, entry, packs };
37
+ }
38
+
39
+ export async function upsertLockfile({ cwd = process.cwd(), registry, manifest, force = false } = {}) {
40
+ const { targetPath, entry, packs } = await validateLockfileUpdate({ cwd, registry, manifest, force });
41
+ const next = {
42
+ registry,
43
+ packs: { ...packs, [manifest.name]: entry },
44
+ };
45
+ await atomicWriteJson(targetPath, next);
46
+ return { path: targetPath, entry };
47
+ }
48
+
49
+ async function readLockfile(filePath) {
50
+ let text;
51
+ try {
52
+ text = await readFile(filePath, 'utf8');
53
+ } catch (error) {
54
+ if (error?.code === 'ENOENT') return { packs: {} };
55
+ throw error;
56
+ }
57
+ let value;
58
+ try {
59
+ value = JSON.parse(text);
60
+ } catch {
61
+ throw new Error(`Invalid JSON in ${LOCKFILE_NAME}.`);
62
+ }
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${LOCKFILE_NAME} must be a JSON object.`);
64
+ if (value.registry !== undefined && typeof value.registry !== 'string') throw new Error(`${LOCKFILE_NAME}.registry must be a string.`);
65
+ if (value.packs !== undefined && (typeof value.packs !== 'object' || Array.isArray(value.packs))) throw new Error(`${LOCKFILE_NAME}.packs must be an object.`);
66
+ return value;
67
+ }
68
+
69
+ async function atomicWriteJson(filePath, value) {
70
+ await mkdir(path.dirname(filePath), { recursive: true });
71
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
72
+ try {
73
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o644 });
74
+ await rename(tempPath, filePath);
75
+ } finally {
76
+ await unlink(tempPath).catch(() => {});
77
+ }
78
+ }
@@ -0,0 +1,41 @@
1
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
2
+
3
+ export function validatePackManifest(value, { expectedName, requestedVersion } = {}) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5
+ throw new Error('Registry returned an invalid pack manifest.');
6
+ }
7
+ if (typeof value.name !== 'string' || !value.name) throw new Error('Registry manifest is missing name.');
8
+ if (expectedName && value.name !== expectedName) throw new Error(`Registry manifest name mismatch: expected ${expectedName}, got ${value.name}.`);
9
+ if (typeof value.version !== 'string' || !value.version) throw new Error('Registry manifest is missing version.');
10
+ if (requestedVersion && requestedVersion !== 'latest' && value.version !== requestedVersion) {
11
+ throw new Error(`Registry manifest version mismatch: expected ${requestedVersion}, got ${value.version}.`);
12
+ }
13
+ if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) {
14
+ throw new Error('Registry manifest sha256 must be 64 lowercase hexadecimal characters.');
15
+ }
16
+ if (typeof value.url !== 'string') throw new Error('Registry manifest is missing url.');
17
+ if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) {
18
+ throw new Error('Registry manifest sizeBytes must be a non-negative integer.');
19
+ }
20
+ if (value.stateRoot !== undefined && typeof value.stateRoot !== 'string') {
21
+ throw new Error('Registry manifest stateRoot must be a string when present.');
22
+ }
23
+ if (value.license !== undefined && typeof value.license !== 'string') {
24
+ throw new Error('Registry manifest license must be a string when present.');
25
+ }
26
+ if (value.yanked !== undefined && typeof value.yanked !== 'boolean') {
27
+ throw new Error('Registry manifest yanked must be a boolean when present.');
28
+ }
29
+
30
+ return {
31
+ name: value.name,
32
+ version: value.version,
33
+ sha256: value.sha256,
34
+ ...(value.stateRoot !== undefined ? { stateRoot: value.stateRoot } : {}),
35
+ url: value.url,
36
+ ...(value.license !== undefined ? { license: value.license } : {}),
37
+ sizeBytes: value.sizeBytes,
38
+ yanked: value.yanked === true,
39
+ ...(value.format !== undefined ? { format: value.format } : {}),
40
+ };
41
+ }
@@ -0,0 +1,20 @@
1
+ const PACK_SPEC_PATTERN = /^(?<publisher>[a-z0-9-]+)\/(?<slug>[a-z0-9-]+)(?:@(?<version>[^@]+))?$/;
2
+
3
+ export function parsePackSpec(value) {
4
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Pack spec must be publisher/slug[@version].');
5
+ const match = PACK_SPEC_PATTERN.exec(value.trim());
6
+ if (!match) throw new Error(`Invalid pack spec: ${value}. Expected publisher/slug[@version].`);
7
+
8
+ return {
9
+ publisher: match.groups.publisher,
10
+ slug: match.groups.slug,
11
+ version: match.groups.version || 'latest',
12
+ name: `${match.groups.publisher}/${match.groups.slug}`,
13
+ };
14
+ }
15
+
16
+ export function parsePackName(value) {
17
+ const parsed = parsePackSpec(value);
18
+ if (value.includes('@')) throw new Error(`Pack name must not include a version: ${value}`);
19
+ return parsed;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knolo/cli",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "knolo": "bin/knolo.mjs"
@@ -28,7 +28,8 @@
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"
31
+ "test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/hub.integration.test.mjs",
32
+ "test:hub": "node --test test/hub.integration.test.mjs"
32
33
  },
33
34
  "dependencies": {
34
35
  "@knolo/core": "^5.1.0"