knowingdog 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1058 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import {
7
+ mkdir,
8
+ readFile,
9
+ writeFile,
10
+ lstat,
11
+ realpath,
12
+ rename,
13
+ rm,
14
+ } from "node:fs/promises";
15
+ import { setTimeout as wait } from "node:timers/promises";
16
+ import { execFile } from "node:child_process";
17
+ import { createInterface } from "node:readline/promises";
18
+ import { promisify } from "node:util";
19
+ const runFile = promisify(execFile);
20
+ const print = (value) => process.stdout.write(`${value}\n`);
21
+
22
+ export function siteOrigin(value) {
23
+ const url = new URL(value);
24
+ if (
25
+ url.username ||
26
+ url.password ||
27
+ url.pathname !== "/" ||
28
+ url.search ||
29
+ url.hash ||
30
+ !(
31
+ url.protocol === "https:" ||
32
+ (url.protocol === "http:" &&
33
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
34
+ )
35
+ )
36
+ throw new Error(
37
+ "Use an HTTPS site origin, or HTTP localhost for development.",
38
+ );
39
+ return url.origin;
40
+ }
41
+ export function checksum(files) {
42
+ return createHash("sha256")
43
+ .update(
44
+ JSON.stringify(
45
+ [...files].sort((a, b) =>
46
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
47
+ ),
48
+ ),
49
+ )
50
+ .digest("hex");
51
+ }
52
+ export function validateArtifact(artifact, expected) {
53
+ if (
54
+ !artifact ||
55
+ artifact.identifier !== expected.identifier ||
56
+ (expected.version && artifact.version !== expected.version) ||
57
+ artifact.format !== expected.format ||
58
+ !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(
59
+ artifact.version,
60
+ ) ||
61
+ !Array.isArray(artifact.files) ||
62
+ !artifact.files.length ||
63
+ artifact.files.length > 1006
64
+ )
65
+ throw new Error("Unexpected release response.");
66
+ const seen = new Set();
67
+ let bytes = 0;
68
+ for (const file of artifact.files) {
69
+ if (
70
+ typeof file.path !== "string" ||
71
+ typeof file.content !== "string" ||
72
+ !/^(skills\/[a-z0-9-]+\/[a-zA-Z0-9_./-]+|README\.md|\.mcp\.json|\.(?:codex|claude)-plugin\/(?:plugin|marketplace)\.json|\.agents\/plugins\/marketplace\.json)$/.test(
73
+ file.path,
74
+ ) ||
75
+ file.path
76
+ .split("/")
77
+ .some((part) => !part || part === "." || part === "..") ||
78
+ seen.has(file.path.toLowerCase())
79
+ )
80
+ throw new Error("Unsafe artifact path.");
81
+ seen.add(file.path.toLowerCase());
82
+ if (
83
+ file.encoding !== undefined &&
84
+ (file.encoding !== "base64" ||
85
+ !/\.(?:png|jpe?g|webp|gif)$/i.test(file.path) ||
86
+ Buffer.from(file.content, "base64").toString("base64") !== file.content)
87
+ )
88
+ throw new Error("Invalid binary reference.");
89
+ const fileBytes = file.encoding
90
+ ? Buffer.from(file.content, "base64").length
91
+ : Buffer.byteLength(file.content);
92
+ bytes += fileBytes;
93
+ if (fileBytes > 500000 || bytes > 12500000 || file.content.includes("\0"))
94
+ throw new Error("Artifact exceeds text-file limits.");
95
+ if (expected.skill && !file.path.startsWith(`skills/${expected.skill}/`))
96
+ throw new Error("Unexpected file outside the selected skill.");
97
+ if (
98
+ file.path.startsWith(".") &&
99
+ !(
100
+ file.path === ".mcp.json" &&
101
+ ["codex-plugin", "claude-plugin"].includes(expected.format)
102
+ ) &&
103
+ !(
104
+ expected.format === "codex-plugin" &&
105
+ file.path === ".agents/plugins/marketplace.json"
106
+ ) &&
107
+ !file.path.startsWith(
108
+ expected.format === "codex-plugin"
109
+ ? ".codex-plugin/"
110
+ : expected.format === "claude-plugin"
111
+ ? ".claude-plugin/"
112
+ : "never/",
113
+ )
114
+ )
115
+ throw new Error("Unexpected plugin manifest.");
116
+ }
117
+ if (expected.format === "codex-plugin") {
118
+ const catalog = JSON.parse(
119
+ artifact.files.find(
120
+ (file) => file.path === ".agents/plugins/marketplace.json",
121
+ )?.content ?? "null",
122
+ );
123
+ const entry = catalog?.plugins?.[0];
124
+ if (
125
+ !catalog ||
126
+ !/^[a-z0-9-]+$/.test(catalog.name) ||
127
+ catalog.plugins?.length !== 1 ||
128
+ entry?.name !==
129
+ JSON.parse(
130
+ artifact.files.find(
131
+ (file) => file.path === ".codex-plugin/plugin.json",
132
+ )?.content ?? "null",
133
+ )?.name ||
134
+ entry.source?.source !== "local" ||
135
+ entry.source?.path !== "./"
136
+ )
137
+ throw new Error("Unexpected Codex marketplace catalog.");
138
+ }
139
+ if (expected.format === "claude-plugin") {
140
+ const marketplace = artifact.files.find(
141
+ (file) => file.path === ".claude-plugin/marketplace.json",
142
+ );
143
+ if (marketplace) {
144
+ const catalog = JSON.parse(marketplace.content);
145
+ const plugin = JSON.parse(
146
+ artifact.files.find(
147
+ (file) => file.path === ".claude-plugin/plugin.json",
148
+ )?.content ?? "null",
149
+ );
150
+ if (
151
+ !/^[a-z0-9-]+$/.test(catalog.name) ||
152
+ typeof catalog.owner?.name !== "string" ||
153
+ catalog.plugins?.length !== 1 ||
154
+ catalog.plugins[0].name !== plugin?.name ||
155
+ catalog.plugins[0].source !== "./"
156
+ )
157
+ throw new Error("Unexpected Claude marketplace catalog.");
158
+ }
159
+ }
160
+ if (checksum(artifact.files) !== artifact.checksum)
161
+ throw new Error("Release checksum mismatch.");
162
+ return artifact;
163
+ }
164
+ async function safeDirectory(directory) {
165
+ const parsed = path.parse(path.resolve(directory));
166
+ let current = parsed.root;
167
+ for (const part of path
168
+ .resolve(directory)
169
+ .slice(parsed.root.length)
170
+ .split(path.sep)) {
171
+ if (!part) continue;
172
+ current = path.join(current, part);
173
+ try {
174
+ await mkdir(current);
175
+ } catch (error) {
176
+ if (error.code !== "EEXIST") throw error;
177
+ }
178
+ const stat = await lstat(current);
179
+ if (stat.isSymbolicLink() || !stat.isDirectory())
180
+ throw new Error(`Unsafe directory: ${current}`);
181
+ }
182
+ }
183
+ export async function installArtifact(artifact, options = {}) {
184
+ const cwd = await realpath(path.resolve(options.cwd ?? process.cwd()));
185
+ const created = [];
186
+ try {
187
+ if (["skills", "space-skill"].includes(artifact.format)) {
188
+ const base = path.join(
189
+ cwd,
190
+ options.agent === "claude" ? ".claude" : ".agents",
191
+ "skills",
192
+ );
193
+ await safeDirectory(base);
194
+ const names = [
195
+ ...new Set(
196
+ artifact.files
197
+ .filter((f) => f.path.startsWith("skills/"))
198
+ .map((f) => f.path.split("/")[1]),
199
+ ),
200
+ ];
201
+ if (!names.length) throw new Error("The release contains no skills.");
202
+ for (const name of names) {
203
+ const dest = path.join(base, name);
204
+ await mkdir(dest);
205
+ created.push(dest);
206
+ }
207
+ for (const file of artifact.files.filter((f) =>
208
+ f.path.startsWith("skills/"),
209
+ )) {
210
+ const dest = path.join(base, file.path.slice(7));
211
+ await safeDirectory(path.dirname(dest));
212
+ await writeFile(
213
+ dest,
214
+ file.encoding ? Buffer.from(file.content, "base64") : file.content,
215
+ { flag: "wx", mode: 0o644 },
216
+ );
217
+ }
218
+ } else {
219
+ const base = path.join(
220
+ cwd,
221
+ ".knowing",
222
+ "plugins",
223
+ artifact.identifier.replace("/", "--"),
224
+ artifact.version,
225
+ );
226
+ await safeDirectory(base);
227
+ const dest = path.join(base, artifact.format);
228
+ await mkdir(dest);
229
+ created.push(dest);
230
+ for (const file of artifact.files) {
231
+ const output = path.join(dest, file.path);
232
+ await safeDirectory(path.dirname(output));
233
+ await writeFile(
234
+ output,
235
+ file.encoding ? Buffer.from(file.content, "base64") : file.content,
236
+ { flag: "wx", mode: 0o644 },
237
+ );
238
+ }
239
+ }
240
+ return created;
241
+ } catch (error) {
242
+ for (const directory of created.reverse())
243
+ await rm(directory, { recursive: true, force: true });
244
+ if (error.code === "EEXIST")
245
+ throw new Error(
246
+ "A destination already exists. Existing files were preserved; choose another project or remove the old installation yourself.",
247
+ );
248
+ throw error;
249
+ }
250
+ }
251
+ async function rpc(origin, procedure, input, mutation, token) {
252
+ const url = new URL(`/api/trpc/knowing.skills.${procedure}`, origin);
253
+ const payload = JSON.stringify({ json: input });
254
+ if (!mutation) url.searchParams.set("input", payload);
255
+ const response = await fetch(url, {
256
+ method: mutation ? "POST" : "GET",
257
+ redirect: "error",
258
+ signal: AbortSignal.timeout(120000),
259
+ headers: {
260
+ ...(mutation ? { "Content-Type": "application/json" } : {}),
261
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
262
+ },
263
+ ...(mutation ? { body: payload } : {}),
264
+ });
265
+ const text = await response.text();
266
+ if (text.length > 25000000) throw new Error("Release response is too large.");
267
+ const data = JSON.parse(text);
268
+ if (
269
+ token &&
270
+ !process.env.KNOWING_INSTALL_TOKEN &&
271
+ !mutation &&
272
+ ["resolve", "artifact", "installAccess"].includes(procedure) &&
273
+ data.error?.json?.data?.code === "UNAUTHORIZED"
274
+ )
275
+ return rpc(origin, procedure, input, false);
276
+ if (!response.ok || data.error)
277
+ throw Object.assign(
278
+ new Error(data.error?.json?.message ?? "Knowing request failed."),
279
+ { code: data.error?.json?.data?.code },
280
+ );
281
+ return data.result.data.json;
282
+ }
283
+ async function configurationDirectory(value) {
284
+ if (!value) return undefined;
285
+ let current = path.resolve(value);
286
+ const missing = [];
287
+ for (;;) {
288
+ try {
289
+ return path.join(await realpath(current), ...missing);
290
+ } catch (error) {
291
+ if (error.code !== "ENOENT") throw error;
292
+ missing.unshift(path.basename(current));
293
+ const parent = path.dirname(current);
294
+ if (parent === current) throw error;
295
+ current = parent;
296
+ }
297
+ }
298
+ }
299
+ function credentialsFile(origin, configDir) {
300
+ return path.join(
301
+ configDir
302
+ ? path.resolve(configDir)
303
+ : path.join(homedir(), ".config", "knowing-skills"),
304
+ createHash("sha256").update(origin).digest("hex") + ".json",
305
+ );
306
+ }
307
+ export async function saveConfiguration(file, value) {
308
+ await safeDirectory(path.dirname(file));
309
+ const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp`;
310
+ try {
311
+ await writeFile(temporary, JSON.stringify(value), {
312
+ flag: "wx",
313
+ mode: 0o600,
314
+ });
315
+ await rename(temporary, file);
316
+ } finally {
317
+ await rm(temporary, { force: true });
318
+ }
319
+ }
320
+ async function credentials(origin, configDir) {
321
+ try {
322
+ return JSON.parse(
323
+ await readFile(credentialsFile(origin, configDir), "utf8"),
324
+ );
325
+ } catch (error) {
326
+ if (error.code === "ENOENT") return {};
327
+ throw error;
328
+ }
329
+ }
330
+
331
+ function openBrowser(url) {
332
+ const command =
333
+ process.platform === "darwin"
334
+ ? "open"
335
+ : process.platform === "win32"
336
+ ? "rundll32"
337
+ : "xdg-open";
338
+ execFile(
339
+ command,
340
+ process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url],
341
+ (error) => {
342
+ // stderr, so `login --install-only | gh secret set` receives only the token.
343
+ if (error)
344
+ process.stderr.write(
345
+ "Open the URL above in your browser to continue.\n",
346
+ );
347
+ },
348
+ );
349
+ }
350
+ export function releaseTarget(value, configuredOrigin) {
351
+ if (/^https?:\/\//.test(value ?? "")) {
352
+ const url = new URL(value);
353
+ const slug = /^\/s\/([^/]+)\/?$/.exec(url.pathname)?.[1];
354
+ if (!slug || url.search || url.hash || url.username || url.password)
355
+ throw new Error(
356
+ "Use the space's root URL, without a page, query or fragment.",
357
+ );
358
+ return {
359
+ origin: siteOrigin(url.origin),
360
+ input: { slug: decodeURIComponent(slug) },
361
+ };
362
+ }
363
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(value ?? ""))
364
+ throw new Error("Provide a space URL or owner/repository.");
365
+ if (!configuredOrigin)
366
+ throw new Error(
367
+ "Use a space URL for your first install, or set --site / KNOWING_SITE. Login remembers the site for later owner/repository commands.",
368
+ );
369
+ return { origin: siteOrigin(configuredOrigin), input: { identifier: value } };
370
+ }
371
+ export function releaseSetup(command, release, options = {}) {
372
+ if (options.free && options.premium)
373
+ throw new Error("Choose --free or --premium.");
374
+ const method = command === "add-agent" ? "agent" : "github";
375
+ if (
376
+ (options.premium && !release.fullAccess) ||
377
+ (!options.free && command !== "add-skill" && release.requiresAccess[method])
378
+ ) {
379
+ // Servers before named tiers send no tiers.
380
+ const tier =
381
+ !options.premium && release.tiers?.[release.methodTiers?.[method]];
382
+ throw Object.assign(
383
+ new Error(
384
+ tier && release.methodTiers[method] > (release.tier ?? 0)
385
+ ? `${method === "agent" ? "The plugin" : "The GitHub Action"} needs the ${tier} tier.`
386
+ : "This setup requires access to the published release.",
387
+ ),
388
+ { code: "FORBIDDEN" },
389
+ );
390
+ }
391
+ if (command === "init-ci") {
392
+ const task = release.source?.github?.tasks.find(
393
+ (item) =>
394
+ item.name === (options.task ?? release.source.github.defaultTask),
395
+ );
396
+ if (!task)
397
+ throw new Error(
398
+ "Define github.defaultTask and github.tasks in root knowing.json at the release commit.",
399
+ );
400
+ return { task };
401
+ }
402
+ if (command === "add-skill") {
403
+ const skill = options.skill;
404
+ if (skill && !release.skills.some((item) => item.name === skill))
405
+ throw Object.assign(
406
+ new Error("The selected skill requires access or is unavailable."),
407
+ { code: "FORBIDDEN" },
408
+ );
409
+ return {
410
+ skill,
411
+ format: skill ? "skills" : "space-skill",
412
+ agent: options.agent,
413
+ };
414
+ }
415
+ if (options.skill)
416
+ throw new Error(
417
+ "Use add-skill --skill for an individual skill; add-plugin follows knowing.json and native plugin manifests.",
418
+ );
419
+ let agent = options.agent ?? release.source?.agent?.target;
420
+ if (agent && !["codex", "claude"].includes(agent))
421
+ throw new Error("Use --agent codex or --agent claude.");
422
+ if (!agent) {
423
+ const plugins = release.formats.filter((format) =>
424
+ ["codex-plugin", "claude-plugin"].includes(format),
425
+ );
426
+ if (plugins.length === 1)
427
+ agent = plugins[0] === "codex-plugin" ? "codex" : "claude";
428
+ else
429
+ throw Object.assign(
430
+ new Error(
431
+ "Choose --agent codex or --agent claude, or define agent.target in knowing.json.",
432
+ ),
433
+ { code: "AGENT_REQUIRED" },
434
+ );
435
+ }
436
+ const plugin = agent === "codex" ? "codex-plugin" : "claude-plugin";
437
+ if (release.formats.includes(plugin)) return { format: plugin, agent };
438
+ const skill = release.source?.agent?.skill;
439
+ if (!skill)
440
+ throw new Error(
441
+ `Define agent.skill in knowing.json or provide a .${agent === "codex" ? "codex" : "claude"}-plugin/plugin.json manifest.`,
442
+ );
443
+ return { format: "skills", skill, agent };
444
+ }
445
+ export function taskWorkflow(release, task, installation) {
446
+ if (
447
+ task.runner !== "github-action" ||
448
+ !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*@[a-f0-9]{40}$/.test(
449
+ task.uses,
450
+ )
451
+ )
452
+ throw new Error(
453
+ "GitHub tasks must use a commit-pinned github-action runner.",
454
+ );
455
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(task.name))
456
+ throw new Error("Invalid task name.");
457
+ const inputs = Object.entries(task.inputs ?? {});
458
+ const secrets = Object.entries(task.secrets ?? {});
459
+ if (
460
+ inputs.some(
461
+ ([key, value]) =>
462
+ !/^[A-Za-z][A-Za-z0-9_-]*$/.test(key) ||
463
+ typeof value !== "string" ||
464
+ value.includes("${{"),
465
+ ) ||
466
+ secrets.some(
467
+ ([key, value]) =>
468
+ !/^[A-Za-z][A-Za-z0-9_-]*$/.test(key) ||
469
+ !/^[A-Z][A-Z0-9_]*$/.test(value),
470
+ )
471
+ )
472
+ throw new Error("Unsafe task inputs or secret names.");
473
+ const permissions = task.permissions;
474
+ if (
475
+ !["read", "write"].includes(permissions.contents) ||
476
+ !["read", "write"].includes(permissions.pullRequests)
477
+ )
478
+ throw new Error("Invalid task permissions.");
479
+ if (Object.keys(task.inputs ?? {}).some((key) => key in (task.secrets ?? {})))
480
+ throw new Error("Duplicate literal and secret input names.");
481
+ if (
482
+ (task.paths ?? []).some(
483
+ (value) =>
484
+ typeof value !== "string" ||
485
+ !/^[A-Za-z0-9_./*!?-]+$/.test(value) ||
486
+ value.startsWith("/") ||
487
+ value.split("/").some((part) => [".", "..", ""].includes(part)),
488
+ )
489
+ )
490
+ throw new Error("Unsafe task path filters.");
491
+ let installSteps = "";
492
+ if (installation) {
493
+ const site = siteOrigin(installation.origin);
494
+ if (
495
+ !/^[a-f0-9]{64}$/.test(installation.digest) ||
496
+ !/^[a-z0-9][a-z0-9-]{0,62}\/[a-z0-9][a-z0-9-]{0,62}$/.test(
497
+ release.identifier,
498
+ ) ||
499
+ !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(
500
+ release.version,
501
+ ) ||
502
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(task.skill)
503
+ )
504
+ throw new Error("Invalid pinned skill installation.");
505
+ const script = `.github/knowing-${task.name}.mjs`;
506
+ installSteps = ` - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020\n with:\n node-version: '24'\n - name: Install published review skill\n${installation.requiresToken ? " env:\n KNOWING_INSTALL_TOKEN: ${{ secrets.KNOWING_INSTALL_TOKEN }}\n" : ""} run: |\n echo '${installation.digest} ${script}' | sha256sum --check\n node ${script} add ${release.identifier} --version ${release.version} --skill ${task.skill} --format skills --channel github --site ${site}\n`;
507
+ }
508
+ const paths = task.paths?.length
509
+ ? ` paths: ${JSON.stringify(task.paths)}\n`
510
+ : "";
511
+ return `name: ${JSON.stringify(`Knowing ${release.name} — ${task.name}`).replaceAll("${{", "$ { {")}\n# Published release ${release.version}; task skill ${task.skill}\non:\n pull_request:\n${paths} workflow_dispatch:\npermissions:\n contents: ${permissions.contents}\n pull-requests: ${permissions.pullRequests}\nconcurrency:\n group: knowing-${task.name}-${"${{ github.event.pull_request.number || github.ref }}"}\n cancel-in-progress: true\njobs:\n ${task.name}:\n${secrets.length || installation?.requiresToken ? " if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n" : ""} runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683\n with:\n persist-credentials: false\n${installSteps} - uses: ${task.uses}\n${inputs.length || secrets.length ? ` with:\n${inputs.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}\n`).join("")}${secrets.map(([key, value]) => ` ${key}: \${{ secrets.${value} }}\n`).join("")}` : ""}`;
512
+ }
513
+ export async function writeSetupFile(file, content, cwd = process.cwd()) {
514
+ const root = await realpath(cwd);
515
+ const destination = path.resolve(root, file);
516
+ const relative = path.relative(root, destination);
517
+ if (
518
+ !relative ||
519
+ relative.startsWith(`..${path.sep}`) ||
520
+ path.isAbsolute(relative)
521
+ )
522
+ throw new Error("Setup files must stay inside the project.");
523
+ await safeDirectory(path.dirname(destination));
524
+ try {
525
+ await writeFile(destination, content, { flag: "wx", mode: 0o644 });
526
+ } catch (error) {
527
+ if (error.code === "EEXIST")
528
+ throw new Error(
529
+ `Existing ${file} was preserved. Review it before replacing it.`,
530
+ );
531
+ throw error;
532
+ }
533
+ return destination;
534
+ }
535
+ async function setupRelease(command, target, flag, switches) {
536
+ const configDir = await configurationDirectory(flag("config-dir"));
537
+ let defaultOrigin;
538
+ try {
539
+ defaultOrigin = JSON.parse(
540
+ await readFile(
541
+ path.join(
542
+ configDir
543
+ ? path.resolve(configDir)
544
+ : path.join(homedir(), ".config", "knowing-skills"),
545
+ "default-site.json",
546
+ ),
547
+ "utf8",
548
+ ),
549
+ ).origin;
550
+ } catch (error) {
551
+ if (error.code !== "ENOENT") throw error;
552
+ }
553
+ const { origin, input } = releaseTarget(
554
+ target,
555
+ flag("site") ?? process.env.KNOWING_SITE ?? defaultOrigin,
556
+ );
557
+ if (flag("version")) input.version = flag("version");
558
+ input.channel =
559
+ command === "add-agent"
560
+ ? "agent"
561
+ : command === "init-ci"
562
+ ? "github"
563
+ : "skills";
564
+ let options = {
565
+ skill: flag("skill"),
566
+ agent: flag("agent"),
567
+ task: flag("task"),
568
+ free: switches.free,
569
+ premium: switches.premium,
570
+ };
571
+ let resolvedRelease;
572
+ let requestedArtifact;
573
+ // After a login for this release, the browser continues to its access page.
574
+ let browserOnAccessPage = false;
575
+ for (let attempt = 0; attempt < 3; attempt++) {
576
+ const auth = await credentials(origin, configDir);
577
+ try {
578
+ const release = await rpc(
579
+ origin,
580
+ "resolve",
581
+ input,
582
+ false,
583
+ process.env.KNOWING_INSTALL_TOKEN ?? auth.token,
584
+ );
585
+ resolvedRelease = release;
586
+ // Every later attempt installs this exact release.
587
+ input.version = release.version;
588
+ let setup;
589
+ try {
590
+ setup = releaseSetup(command, release, options);
591
+ } catch (error) {
592
+ if (error.code !== "AGENT_REQUIRED" || !process.stdin.isTTY)
593
+ throw error;
594
+ const prompt = createInterface({
595
+ input: process.stdin,
596
+ output: process.stdout,
597
+ });
598
+ try {
599
+ options.agent = (
600
+ await prompt.question("Install for codex or claude? ")
601
+ ).trim();
602
+ } finally {
603
+ prompt.close();
604
+ }
605
+ setup = releaseSetup(command, release, options);
606
+ }
607
+ if (command === "init-ci") {
608
+ const selectedSkill = release.skills.find(
609
+ (item) => item.name === setup.task.skill,
610
+ );
611
+ const requiresToken =
612
+ selectedSkill.access === "restricted" ||
613
+ selectedSkill.tier === "paid" ||
614
+ (release.methodTiers?.github ?? 0) > 0;
615
+ const script = await readFile(new URL(import.meta.url), "utf8");
616
+ const digest = createHash("sha256").update(script).digest("hex");
617
+ const helper = await writeSetupFile(
618
+ `.github/knowing-${setup.task.name}.mjs`,
619
+ script,
620
+ );
621
+ let file;
622
+ try {
623
+ file = await writeSetupFile(
624
+ `.github/workflows/knowing-${setup.task.name}.yml`,
625
+ taskWorkflow(release, setup.task, {
626
+ origin,
627
+ digest,
628
+ requiresToken,
629
+ }),
630
+ );
631
+ } catch (error) {
632
+ await rm(helper);
633
+ throw error;
634
+ }
635
+ if (requiresToken)
636
+ print(
637
+ `Set the KNOWING_INSTALL_TOKEN repository secret to an install-only token with access to this release:\nknowing login --site ${origin} --install-only | gh secret set KNOWING_INSTALL_TOKEN\nIt only installs releases, never reads your spaces or uses hosted answers, expires after 30 days and can be revoked from Authorized devices.`,
638
+ );
639
+ print(
640
+ `Created ${file}\nRelease ${release.version}, task ${setup.task.name}. Commit and push after reviewing the workflow.`,
641
+ );
642
+ if (Object.keys(setup.task.secrets).length)
643
+ print(
644
+ `Set these GitHub Actions secrets before running: ${[...new Set(Object.values(setup.task.secrets))].join(", ")}`,
645
+ );
646
+ return;
647
+ }
648
+ const expected = {
649
+ identifier: release.identifier,
650
+ version: release.version,
651
+ channel: input.channel,
652
+ skill: setup.skill,
653
+ format: setup.format,
654
+ ...(options.free
655
+ ? { tier: "free" }
656
+ : options.premium
657
+ ? { tier: "paid" }
658
+ : {}),
659
+ };
660
+ requestedArtifact = expected;
661
+ const artifact = validateArtifact(
662
+ await rpc(
663
+ origin,
664
+ "artifact",
665
+ expected,
666
+ false,
667
+ process.env.KNOWING_INSTALL_TOKEN ?? auth.token,
668
+ ),
669
+ expected,
670
+ );
671
+ // Create the MCP file first so an existing configuration cannot leave a partial skill installation.
672
+ let mcpFile;
673
+ if (command === "add-agent" && setup.format === "skills" && release.mcp) {
674
+ if (setup.agent !== "claude")
675
+ throw new Error(
676
+ "Repo MCP configuration for Codex requires a Codex plugin manifest. Existing Codex configuration is never overwritten.",
677
+ );
678
+ const mcp = JSON.parse(release.mcp);
679
+ if (
680
+ !mcp ||
681
+ typeof mcp.mcpServers !== "object" ||
682
+ Array.isArray(mcp.mcpServers)
683
+ )
684
+ throw new Error(".mcp.json must contain mcpServers.");
685
+ mcpFile = await writeSetupFile(".mcp.json", release.mcp);
686
+ }
687
+ let directories;
688
+ try {
689
+ directories = await installArtifact(artifact, { agent: setup.agent });
690
+ } catch (error) {
691
+ if (mcpFile) await rm(mcpFile);
692
+ throw error;
693
+ }
694
+ print(
695
+ `Installed ${release.name}@${release.version}:\n${directories.join("\n")}`,
696
+ );
697
+ if (
698
+ command === "add-skill" &&
699
+ !setup.skill &&
700
+ !options.free &&
701
+ !release.fullAccess
702
+ )
703
+ print(
704
+ `More of this release is in paid tiers: ${releaseAccessUrl(origin, release.releaseId)}`,
705
+ );
706
+ const registrationDirectory = await preparePluginRegistration(
707
+ artifact,
708
+ directories[0],
709
+ );
710
+ printPluginRegistration(artifact, [registrationDirectory]);
711
+ if (
712
+ command === "add-agent" &&
713
+ artifact.format.endsWith("-plugin") &&
714
+ !switches.noRegister
715
+ ) {
716
+ try {
717
+ await registerPlugin(artifact, registrationDirectory);
718
+ } catch (error) {
719
+ throw new Error(
720
+ `Plugin files are ready at ${directories[0]}, but native registration failed: ${error.message}. Use the printed commands to finish setup.`,
721
+ );
722
+ }
723
+ print("Plugin registered. Restart your coding agent to load it.");
724
+ }
725
+ return;
726
+ } catch (error) {
727
+ if (!["FORBIDDEN", "UNAUTHORIZED"].includes(error.code)) {
728
+ // A private space looks missing to visitors who aren't signed in.
729
+ if (
730
+ error.code === "NOT_FOUND" &&
731
+ !resolvedRelease &&
732
+ !auth.token &&
733
+ !process.env.KNOWING_INSTALL_TOKEN
734
+ )
735
+ error.message += ` If this space is private, run knowing login --site ${origin} and try again.`;
736
+ throw error;
737
+ }
738
+ if (!resolvedRelease) throw error;
739
+ const token = process.env.KNOWING_INSTALL_TOKEN ?? auth.token;
740
+ // The setup check runs before an artifact request exists.
741
+ const request = requestedArtifact ?? {
742
+ identifier: resolvedRelease.identifier,
743
+ version: resolvedRelease.version,
744
+ channel: input.channel,
745
+ ...(command === "add-skill" && options.skill
746
+ ? { skill: options.skill }
747
+ : {}),
748
+ ...(options.premium ? { tier: "paid" } : {}),
749
+ };
750
+ const check = () => rpc(origin, "installAccess", request, false, token);
751
+ let access = await check();
752
+ const accessUrl = releaseAccessUrl(origin, access.releaseId);
753
+ const needs = access.tier
754
+ ? `This needs the ${access.tier} tier.`
755
+ : "This needs access to the release.";
756
+ // Revoked access and access that isn't sold can't be fixed in a browser.
757
+ const stopUnlessBuyable = () => {
758
+ if (access.status === "revoked")
759
+ throw new Error(
760
+ `Your access to this release was revoked. Contact the creator, or see ${accessUrl}`,
761
+ );
762
+ if (access.status === "unavailable")
763
+ throw new Error(`${needs} It isn't for sale right now: ${accessUrl}`);
764
+ };
765
+ if (access.status === "granted") {
766
+ if (attempt < 2) continue;
767
+ throw new Error(`${error.message} Run the command again.`);
768
+ }
769
+ stopUnlessBuyable();
770
+ // Unattended runs can't sign in or pay, so they stop with the next step.
771
+ const unattended = process.env.CI || process.env.KNOWING_INSTALL_TOKEN;
772
+ if (unattended || attempt === 2)
773
+ throw new Error(
774
+ `${needs} Get access with your Knowing account at ${accessUrl}, then run the command again.${unattended ? ` CI installs with a KNOWING_INSTALL_TOKEN from that account: knowing login --site ${origin} --install-only` : ""}`,
775
+ );
776
+ if (access.status === "signin") {
777
+ print(
778
+ "This installation needs your Knowing account. Sign in in your browser; installation continues automatically.",
779
+ );
780
+ await main([
781
+ "login",
782
+ "--site",
783
+ origin,
784
+ "--release",
785
+ access.releaseId,
786
+ ...(configDir ? ["--config-dir", configDir] : []),
787
+ ...(switches.noBrowser ? ["--no-browser"] : []),
788
+ ]);
789
+ browserOnAccessPage = true;
790
+ continue;
791
+ }
792
+ print(
793
+ `${needs} Get access in your browser: ${accessUrl}\nInstallation continues automatically after access is confirmed. If this command stops, run it again after paying.`,
794
+ );
795
+ if (!switches.noBrowser && !browserOnAccessPage) openBrowser(accessUrl);
796
+ browserOnAccessPage = true;
797
+ access = await waitForReleaseAccess(check);
798
+ stopUnlessBuyable();
799
+ }
800
+ }
801
+ }
802
+ /** The release's access options, through sign-in when the browser needs it. */
803
+ export function releaseAccessUrl(origin, releaseId) {
804
+ return `${origin}/join?callbackURL=${encodeURIComponent(`/skills?release=${encodeURIComponent(releaseId)}`)}`;
805
+ }
806
+ /**
807
+ * Polls an install's access while the buyer can still act in the browser,
808
+ * and returns it once they can't: granted, revoked or not for sale. Network
809
+ * failures are retried a few times; server answers are not.
810
+ */
811
+ export async function waitForReleaseAccess(
812
+ load,
813
+ {
814
+ timeoutMs = 600000,
815
+ pollMs = 3000,
816
+ retries = 3,
817
+ sleep = wait,
818
+ now = () => performance.now(),
819
+ } = {},
820
+ ) {
821
+ const deadline = now() + timeoutMs;
822
+ let failures = 0;
823
+ while (now() < deadline) {
824
+ let access;
825
+ try {
826
+ access = await load();
827
+ failures = 0;
828
+ } catch (error) {
829
+ if (error.code || ++failures > retries) throw error;
830
+ }
831
+ if (access && !["signin", "purchase"].includes(access.status))
832
+ return access;
833
+ await sleep(pollMs);
834
+ }
835
+ throw new Error(
836
+ "Access was not confirmed in time. Finish in your browser, then run the install command again.",
837
+ );
838
+ }
839
+ export async function preparePluginRegistration(artifact, directory) {
840
+ if (artifact.format !== "codex-plugin") return directory;
841
+ const root = path.dirname(await realpath(directory));
842
+ const catalog = JSON.parse(
843
+ artifact.files.find(
844
+ (file) => file.path === ".agents/plugins/marketplace.json",
845
+ ).content,
846
+ );
847
+ catalog.plugins[0].source.path = `./${path.basename(directory)}`;
848
+ await writeSetupFile(
849
+ ".agents/plugins/marketplace.json",
850
+ JSON.stringify(catalog, null, 2),
851
+ root,
852
+ );
853
+ return root;
854
+ }
855
+ export async function registerPlugin(artifact, directory, run = runFile) {
856
+ const codex = artifact.format === "codex-plugin";
857
+ const file = codex
858
+ ? ".agents/plugins/marketplace.json"
859
+ : ".claude-plugin/marketplace.json";
860
+ const catalog = JSON.parse(
861
+ artifact.files.find((item) => item.path === file).content,
862
+ );
863
+ const command = codex ? "codex" : "claude";
864
+ await run(command, ["plugin", "marketplace", "add", directory], {
865
+ timeout: 120000,
866
+ });
867
+ await run(
868
+ command,
869
+ [
870
+ "plugin",
871
+ codex ? "add" : "install",
872
+ `${catalog.plugins[0].name}@${catalog.name}`,
873
+ ],
874
+ { timeout: 120000 },
875
+ );
876
+ }
877
+ function printPluginRegistration(artifact, directories) {
878
+ const directory = `'${directories[0].replaceAll("'", "'\\''")}'`;
879
+ if (artifact.format === "claude-plugin") {
880
+ const catalog = artifact.files.find(
881
+ (file) => file.path === ".claude-plugin/marketplace.json",
882
+ );
883
+ if (catalog) {
884
+ const data = JSON.parse(catalog.content);
885
+ print(
886
+ `Register and install with:\nclaude plugin marketplace add ${directory}\nclaude plugin install ${data.plugins[0].name}@${data.name}`,
887
+ );
888
+ } else print(`Load with: claude --plugin-dir ${directory}`);
889
+ }
890
+ if (artifact.format === "codex-plugin") {
891
+ const catalog = JSON.parse(
892
+ artifact.files.find(
893
+ (file) => file.path === ".agents/plugins/marketplace.json",
894
+ ).content,
895
+ );
896
+ print(
897
+ `Register and install with:\ncodex plugin marketplace add ${directory}\ncodex plugin add ${catalog.plugins[0].name}@${catalog.name}`,
898
+ );
899
+ }
900
+ }
901
+
902
+ export async function main(args) {
903
+ const [name, ...rest] = args;
904
+ // add-agent is the earlier name for add-plugin.
905
+ const command = name === "add-plugin" ? "add-agent" : name;
906
+ const flag = (name) => {
907
+ const index = rest.indexOf(`--${name}`);
908
+ return index < 0 ? undefined : rest[index + 1];
909
+ };
910
+ if (["add-skill", "add-agent", "init-ci"].includes(command)) {
911
+ await setupRelease(command, rest[0], flag, {
912
+ free: rest.includes("--free"),
913
+ premium: rest.includes("--premium"),
914
+ noBrowser: rest.includes("--no-browser"),
915
+ noRegister: rest.includes("--no-register"),
916
+ });
917
+ return;
918
+ }
919
+ if (!["login", "logout", "token", "add"].includes(command) || !flag("site")) {
920
+ print(
921
+ "Usage: node knowing-skills.mjs add-skill|add-plugin|init-ci SPACE_URL [--agent codex|claude]\n node knowing-skills.mjs login|logout|token --site https://your-site\n node knowing-skills.mjs login --site https://your-site --install-only (prints a CI install token)\n node knowing-skills.mjs add author/package --version 1.0.0 [--skill name] [--format skills|space-skill|codex-plugin|claude-plugin] [--agent codex|claude] --site https://your-site",
922
+ );
923
+ return;
924
+ }
925
+ const origin = siteOrigin(flag("site"));
926
+ const configDir = await configurationDirectory(flag("config-dir"));
927
+ if (command === "logout") {
928
+ await rm(credentialsFile(origin, configDir), { force: true });
929
+ print(
930
+ "Local token removed. Revoke the terminal in Account settings → Authorized devices to disable it on the server.",
931
+ );
932
+ return;
933
+ }
934
+ if (command === "token") {
935
+ const { token } = await credentials(origin, configDir);
936
+ if (!token?.startsWith("knc_"))
937
+ throw new Error(
938
+ `Run knowing login --site ${origin} to get a customer token.`,
939
+ );
940
+ print(token);
941
+ return;
942
+ }
943
+ if (command === "login") {
944
+ // An install-only token is for the KNOWING_INSTALL_TOKEN CI secret: it
945
+ // can't read spaces over MCP, so it's printed alone on stdout and never
946
+ // replaces the saved customer login.
947
+ const installOnly = rest.includes("--install-only");
948
+ const note = installOnly
949
+ ? (value) => process.stderr.write(`${value}\n`)
950
+ : print;
951
+ const request = await rpc(
952
+ origin,
953
+ "startDevice",
954
+ {
955
+ label:
956
+ flag("label") ??
957
+ `${installOnly ? "CI install" : "Terminal"} ${randomBytes(3).toString("hex")}`,
958
+ ...(installOnly ? { installOnly } : {}),
959
+ },
960
+ true,
961
+ );
962
+ // An install waiting on a release continues to its access page.
963
+ const authorizeUrl = `${origin}/skills/authorize?code=${request.code}${flag("release") ? `&release=${encodeURIComponent(flag("release"))}` : ""}`;
964
+ note(
965
+ `Open ${authorizeUrl}\nConfirm code ${request.code} in your browser. Waiting for approval…`,
966
+ );
967
+ if (!rest.includes("--no-browser")) openBrowser(authorizeUrl);
968
+ const deadline = Date.now() + request.expiresIn * 1000;
969
+ while (Date.now() < deadline) {
970
+ await wait(request.interval * 1000);
971
+ const result = await rpc(
972
+ origin,
973
+ "pollDevice",
974
+ { deviceCode: request.deviceCode },
975
+ true,
976
+ );
977
+ if (result.status !== "approved") continue;
978
+ if (installOnly) {
979
+ if (result.token.startsWith("knc_"))
980
+ throw new Error(
981
+ "This Knowing site can't issue install-only tokens yet. Revoke the new terminal under Authorized devices.",
982
+ );
983
+ print(result.token);
984
+ note(
985
+ "Store this install-only token as the KNOWING_INSTALL_TOKEN repository secret. It isn't saved on this machine and expires in 30 days.",
986
+ );
987
+ return;
988
+ }
989
+ const file = credentialsFile(origin, configDir);
990
+ await saveConfiguration(file, { token: result.token });
991
+ const defaults = path.join(path.dirname(file), "default-site.json");
992
+ await saveConfiguration(defaults, { origin });
993
+ print("Connected. Your token is stored locally and expires in 30 days.");
994
+ return;
995
+ }
996
+ throw new Error("Login expired. Start login again.");
997
+ }
998
+ const identifier = rest[0];
999
+ const version = flag("version");
1000
+ const skill = flag("skill");
1001
+ const format = flag("format") ?? "skills";
1002
+ if (
1003
+ !/^[a-z0-9][a-z0-9-]{0,62}\/[a-z0-9][a-z0-9-]{0,62}$/.test(
1004
+ identifier ?? "",
1005
+ ) ||
1006
+ !["skills", "space-skill", "codex-plugin", "claude-plugin"].includes(
1007
+ format,
1008
+ ) ||
1009
+ (skill && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill))
1010
+ )
1011
+ throw new Error("Invalid package, format or skill.");
1012
+ const auth = await credentials(origin, configDir);
1013
+ const expected = {
1014
+ identifier,
1015
+ version,
1016
+ skill,
1017
+ format,
1018
+ ...(flag("channel") ? { channel: flag("channel") } : {}),
1019
+ };
1020
+ const token = process.env.KNOWING_INSTALL_TOKEN ?? auth.token;
1021
+ let artifact;
1022
+ try {
1023
+ artifact = validateArtifact(
1024
+ await rpc(origin, "artifact", expected, false, token),
1025
+ expected,
1026
+ );
1027
+ } catch (error) {
1028
+ if (!["FORBIDDEN", "UNAUTHORIZED"].includes(error.code)) throw error;
1029
+ // Older servers have no access check; the original error still stands.
1030
+ const access = await rpc(
1031
+ origin,
1032
+ "installAccess",
1033
+ expected,
1034
+ false,
1035
+ token,
1036
+ ).catch(() => undefined);
1037
+ throw new Error(
1038
+ `${error.message}${access ? ` Get access with your Knowing account at ${releaseAccessUrl(origin, access.releaseId)}, then run the command again.` : ""}${process.env.CI || process.env.KNOWING_INSTALL_TOKEN ? ` CI installs with a KNOWING_INSTALL_TOKEN from that account: knowing login --site ${origin} --install-only` : ""}`,
1039
+ );
1040
+ }
1041
+ const directories = await installArtifact(artifact, { agent: flag("agent") });
1042
+ print(
1043
+ `Installed ${identifier}@${artifact.version}:\n${directories.join("\n")}`,
1044
+ );
1045
+ const registrationDirectory = await preparePluginRegistration(
1046
+ artifact,
1047
+ directories[0],
1048
+ );
1049
+ printPluginRegistration(artifact, [registrationDirectory]);
1050
+ }
1051
+ if (
1052
+ process.argv[1] &&
1053
+ import.meta.url === pathToFileURL(await realpath(process.argv[1])).href
1054
+ )
1055
+ main(process.argv.slice(2)).catch((error) => {
1056
+ console.error(error.message);
1057
+ process.exitCode = 1;
1058
+ });