toolaby 1.4.5 → 1.6.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 +2 -0
- package/bin.mjs +170 -24
- package/lib/check.js +190 -0
- package/lib/manifest.js +26 -6
- package/lib/mcp.js +236 -0
- package/lib/project.js +18 -4
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
24
24
|
|
|
25
25
|
**`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
|
|
26
26
|
|
|
27
|
+
**`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. Nothing is sent anywhere.
|
|
28
|
+
|
|
27
29
|
**`toolaby tools`** lists your tools and their ids. **`toolaby whoami`** says who you are signed in as. **`toolaby logout`** signs the command out and revokes its session on the server.
|
|
28
30
|
|
|
29
31
|
## Where things live
|
package/bin.mjs
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// toolaby wire <tool-id> wire the extension in this folder to your tool — plain, WXT, CRXJS or Plasmo
|
|
6
6
|
// toolaby create <tool-id> make a new, minimal extension for your tool in <tool-id>/
|
|
7
7
|
// toolaby tools your tools, with the ids the commands want
|
|
8
|
+
// toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's key left out
|
|
9
|
+
// toolaby check [--into <folder>] whether the extension here is wired right; --browser loads it in Chromium too
|
|
10
|
+
// toolaby mcp the Wall for coding agents: an MCP server on stdio (lib/mcp.js)
|
|
8
11
|
// toolaby webhooks events the six events, with an example of each
|
|
9
12
|
// toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
|
|
10
13
|
// toolaby webhooks endpoints the workspace's endpoints
|
|
@@ -27,14 +30,17 @@
|
|
|
27
30
|
// platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
|
|
28
31
|
// so this and the dashboard's Wire button never disagree.
|
|
29
32
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
|
|
30
|
-
import { join, resolve, dirname } from 'node:path';
|
|
33
|
+
import { join, resolve, dirname, relative, basename } from 'node:path';
|
|
34
|
+
import { deflateRawSync } from 'node:zlib';
|
|
31
35
|
import { homedir } from 'node:os';
|
|
32
36
|
import { spawn } from 'node:child_process';
|
|
33
37
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
34
|
-
import { wireManifest, WORKER_LINES } from './lib/manifest.js';
|
|
35
|
-
import { detectProject, CODE_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
|
|
38
|
+
import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
|
|
39
|
+
import { detectProject, CODE_FILES, POPUP_FILES, SIDE_PANEL_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE, AGENT_FILES, AGENT_ENTRY_FILES } from './lib/project.js';
|
|
40
|
+
import { runMcp } from './lib/mcp.js';
|
|
41
|
+
import { runCheck } from './lib/check.js';
|
|
36
42
|
|
|
37
|
-
const VERSION = '1.
|
|
43
|
+
const VERSION = '1.6.0';
|
|
38
44
|
const CLIENT_ID = 'toolaby-cli';
|
|
39
45
|
const argv = process.argv.slice(2);
|
|
40
46
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -167,14 +173,16 @@ function listTree(dir, depth, prefix = '', acc = new Set()) {
|
|
|
167
173
|
return acc;
|
|
168
174
|
}
|
|
169
175
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
176
|
+
/** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
|
|
177
|
+
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
|
|
170
178
|
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
171
179
|
|
|
172
180
|
/** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
|
|
173
|
-
async function filesFor(slug, scaffold, manifestKey = null) {
|
|
181
|
+
async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
174
182
|
const token = await tokenOrLogin();
|
|
175
|
-
const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey);
|
|
183
|
+
const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey); if (surfaces) q.set('surfaces', surfaces);
|
|
176
184
|
const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
|
|
177
|
-
if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey); }
|
|
185
|
+
if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey, surfaces); }
|
|
178
186
|
if (res.status === 404) {
|
|
179
187
|
// Say who the command is: the tool may well exist — in a workspace of another account of theirs.
|
|
180
188
|
const me = await api('/api/cli/me', { token }).catch(() => null);
|
|
@@ -208,17 +216,25 @@ async function wire() {
|
|
|
208
216
|
const oldConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
209
217
|
const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
|
|
210
218
|
const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
|
|
211
|
-
|
|
219
|
+
// An extension whose own pages draw the panel — what `create` makes, or "your popup first" — keeps them in front.
|
|
220
|
+
const ownInFront = drawsItself(manifest, oldConfig);
|
|
221
|
+
const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, rememberedSidePanel, manifestKey, ownInFront }) : null;
|
|
212
222
|
const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
|
|
213
223
|
const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
|
|
214
|
-
const targets = filesToWrite(project, appPopup, appSidePanel);
|
|
224
|
+
const targets = filesToWrite(project, appPopup, appSidePanel, { popup: !!wiring?.ownPopup, sidePanel: !!wiring?.ownSidePanel });
|
|
215
225
|
const present = targets.filter((f) => existsSync(join(dir, f)));
|
|
216
226
|
if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
|
|
217
227
|
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
218
228
|
// A command older than the Wall asks for files the Wall no longer hands out: said, with the way to the current one, rather than a crash.
|
|
219
|
-
|
|
229
|
+
// The agent files come from Walls that write them; one that does not is older, not newer, than this command.
|
|
230
|
+
const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
|
|
231
|
+
const missing = targets.filter((p) => !agentPaths.has(p)).map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
|
|
220
232
|
if (missing.length) fail(`This command (${VERSION}) is older than the Wall at ${wall}: it expects ${missing.join(', ')}, which the Wall no longer hands out.\n Update it: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz ${command} ${slug}`);
|
|
221
|
-
|
|
233
|
+
const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
|
|
234
|
+
for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
|
|
235
|
+
// AGENTS.md and CLAUDE.md, for coding agents, only where the project has none: its own are never replaced.
|
|
236
|
+
for (const f of AGENT_ENTRY_FILES) if (!has(f) && typeof plan.files[f] === 'string') { write(f, plan.files[f]); wrote.push(f); }
|
|
237
|
+
const ownAgents = has('AGENTS.md') && !readFileSync(join(dir, 'AGENTS.md'), 'utf8').includes('TOOLABY.md');
|
|
222
238
|
// What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
|
|
223
239
|
const removed = [];
|
|
224
240
|
for (const at of new Set([project.codeDir, project.popupDir, 'public/', 'src/public/'])) for (const f of STALE_FILES) { const p = join(dir, `${at}${f}`); if (existsSync(p)) { unlinkSync(p); removed.push(`${at}${f}`); } }
|
|
@@ -228,17 +244,18 @@ async function wire() {
|
|
|
228
244
|
if (wiring.writeWorker) write(wiring.writeWorker, WORKER_LINES);
|
|
229
245
|
if (wiring.did.length) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
|
|
230
246
|
did.push(...wiring.did);
|
|
231
|
-
if (wiring.ownWorker) ownWorker = wiring.ownWorker;
|
|
247
|
+
if (wiring.ownWorker && !startsPlatform(join(dir, wiring.ownWorker.file))) ownWorker = wiring.ownWorker;
|
|
232
248
|
} else if (project.createWorker) {
|
|
233
249
|
write(project.createWorker.file, project.createWorker.content);
|
|
234
250
|
did.push(`a background entry, ${project.createWorker.file} (it starts the platform)`);
|
|
235
|
-
} else if (project.worker && !project.extraFiles) {
|
|
251
|
+
} else if (project.worker && !project.extraFiles && !startsPlatform(join(dir, project.worker))) {
|
|
236
252
|
// A framework with its own extension point (WXT's module) starts the platform in the background itself.
|
|
237
253
|
ownWorker = { file: project.worker, classic: false };
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
241
|
-
out(` Written: ${
|
|
257
|
+
out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
|
|
258
|
+
if (ownAgents) out(' Your AGENTS.md is your own: add a line so coding agents find the Wall\'s guide — "Before changing anything paid, read TOOLABY.md."\n');
|
|
242
259
|
if (removed.length) out(` Removed: ${removed.join(', ')} — earlier files, now inside toolaby.js.\n`);
|
|
243
260
|
for (const sn of project.snippets) out(` Add to ${sn.file} — yours to paste, the manifest being generated:\n\n${sn.code.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
244
261
|
if (ownWorker) out(` Your background, ${ownWorker.file}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
@@ -246,6 +263,7 @@ async function wire() {
|
|
|
246
263
|
out(` Next: ${project.kind === 'plain' && !project.built ? '' : 'build or start the dev server, then '}chrome://extensions → reload it. The tool's Set up page says "checked in" the moment it starts.`);
|
|
247
264
|
if (project.note) out(` ${project.note}\n`);
|
|
248
265
|
else if (appPopup || appSidePanel) out(` Your ${appPopup && appSidePanel ? 'popup and side panel open' : appSidePanel ? 'side panel opens' : 'popup opens'} as before; what the Free plan holds (Pricing, in the dashboard) decides when the Wall's panel takes its place.\n`);
|
|
266
|
+
else if (ownInFront && (wiring?.ownPopup || wiring?.ownSidePanel)) out(` Your ${wiring.ownPopup && wiring.ownSidePanel ? 'popup and side panel stay' : wiring.ownSidePanel ? 'side panel stays' : 'popup stays'} in front and draw${wiring.ownPopup && wiring.ownSidePanel ? '' : 's'} the panel, as before; toolaby.showPaywall() opens the Wall's page beside it.\n`);
|
|
249
267
|
else out(` No popup or side panel to stand in front of: count a use where your feature runs — the Set up page shows the line.\n`);
|
|
250
268
|
}
|
|
251
269
|
|
|
@@ -254,30 +272,36 @@ async function create() {
|
|
|
254
272
|
if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
|
|
255
273
|
const dir = resolve(opt('--dir') ?? slug);
|
|
256
274
|
if (existsSync(dir)) fail(`${dir} already exists`);
|
|
257
|
-
|
|
275
|
+
// Which surfaces this extension is made with, asked first: the files for coding agents name only what is written.
|
|
276
|
+
const asked = opt('--surfaces');
|
|
277
|
+
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
278
|
+
const surfaces = surfacesOf(asked);
|
|
279
|
+
const plan = await filesFor(slug, true, null, surfaces);
|
|
258
280
|
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
259
281
|
// The scaffold's own files as the Wall names them (older Walls did not, hence the list): a Wall that adds one to the
|
|
260
282
|
// minimal extension is written by this command too, instead of a manifest naming a file that never arrived.
|
|
261
283
|
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
266
|
-
const surfaces = surfacesOf(asked);
|
|
267
|
-
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold)];
|
|
284
|
+
// Both surfaces (the default), one of them, or none at all for a tool that works from the background — and beside each,
|
|
285
|
+
// the Wall's page for it, which toolaby.showPaywall() opens when an action is refused (the extension's own pages stay in front).
|
|
286
|
+
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold), ...(hasPopup(surfaces) ? POPUP_FILES : []), ...(hasSidePanel(surfaces) ? SIDE_PANEL_FILES : [])];
|
|
268
287
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
269
288
|
if (absent.length) fail(`The Wall at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
|
|
270
289
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
271
290
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
272
291
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
273
292
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
274
|
-
|
|
293
|
+
// The extension's identity too: every copy gets the id the tool key is bound to, wherever its folder is.
|
|
294
|
+
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces), key: plan.identity?.manifestKey ?? null }), null, 2) + '\n');
|
|
275
295
|
for (const s of [16, 48, 128]) {
|
|
276
296
|
const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
|
|
277
297
|
write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
|
|
278
298
|
}
|
|
299
|
+
// What a coding agent reads: the guide with this tool's features and plans, the skill, AGENTS.md and CLAUDE.md.
|
|
300
|
+
for (const f of AGENT_FILES) if (typeof plan.files[f.name] === 'string') write(f.path, plan.files[f.name]);
|
|
301
|
+
for (const f of AGENT_ENTRY_FILES) if (typeof plan.files[f] === 'string') write(f, plan.files[f]);
|
|
279
302
|
const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
|
|
280
|
-
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.site}/tools/${slug}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${
|
|
303
|
+
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.buyer ?? `${plan.site}/tools/${slug}`}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${wall}/docs/surfaces\n`);
|
|
304
|
+
if (typeof plan.files['TOOLABY.md'] === 'string') out(` With a coding agent: AGENTS.md and TOOLABY.md tell it how this extension sells. For Claude Code, the Wall itself:\n claude mcp add toolaby -- npx -y toolaby@latest mcp${live ? ' --live' : ''}\n`);
|
|
281
305
|
}
|
|
282
306
|
|
|
283
307
|
// ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
|
|
@@ -364,6 +388,122 @@ async function webhooks() {
|
|
|
364
388
|
fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
|
|
365
389
|
}
|
|
366
390
|
|
|
391
|
+
// ── pack: the zip the Chrome Web Store takes ──────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// The built extension, zipped, with the manifest's `key` left out: the store refuses a key in a new item's first
|
|
393
|
+
// package and any key but the item's own in an update, and it keeps the id it gave the item either way — the Wall
|
|
394
|
+
// admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
|
|
395
|
+
// or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
|
|
396
|
+
// can ship with. Nothing is sent anywhere: it reads the folder and writes one file.
|
|
397
|
+
const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
|
|
398
|
+
const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
399
|
+
const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
|
|
400
|
+
|
|
401
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
402
|
+
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
403
|
+
|
|
404
|
+
/** A zip of [name, bytes] pairs: deflated where that is smaller, stored otherwise, every entry dated 1 Jan 1980 so the same build packs to the same bytes. */
|
|
405
|
+
function zipOf(entries) {
|
|
406
|
+
const locals = []; const centrals = []; let offset = 0;
|
|
407
|
+
for (const [name, data] of entries) {
|
|
408
|
+
const nameBuf = Buffer.from(name, 'utf8');
|
|
409
|
+
const deflated = deflateRawSync(data, { level: 9 });
|
|
410
|
+
const method = deflated.length < data.length ? 8 : 0;
|
|
411
|
+
const body = method === 8 ? deflated : data;
|
|
412
|
+
const crc = crc32(data);
|
|
413
|
+
const head = Buffer.alloc(30);
|
|
414
|
+
head.writeUInt32LE(0x04034b50, 0); head.writeUInt16LE(20, 4); head.writeUInt16LE(0x0800, 6); head.writeUInt16LE(method, 8);
|
|
415
|
+
head.writeUInt16LE(0, 10); head.writeUInt16LE(0x21, 12); head.writeUInt32LE(crc, 14); head.writeUInt32LE(body.length, 18);
|
|
416
|
+
head.writeUInt32LE(data.length, 22); head.writeUInt16LE(nameBuf.length, 26); head.writeUInt16LE(0, 28);
|
|
417
|
+
const central = Buffer.alloc(46);
|
|
418
|
+
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8);
|
|
419
|
+
central.writeUInt16LE(method, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14); central.writeUInt32LE(crc, 16);
|
|
420
|
+
central.writeUInt32LE(body.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(nameBuf.length, 28);
|
|
421
|
+
central.writeUInt32LE(offset, 42);
|
|
422
|
+
locals.push(head, nameBuf, body); centrals.push(central, nameBuf);
|
|
423
|
+
offset += head.length + nameBuf.length + body.length;
|
|
424
|
+
}
|
|
425
|
+
const cd = Buffer.concat(centrals);
|
|
426
|
+
const end = Buffer.alloc(22);
|
|
427
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
|
|
428
|
+
end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
|
|
429
|
+
return Buffer.concat([...locals, cd, end]);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function filesIn(root) {
|
|
433
|
+
const found = [];
|
|
434
|
+
const walk = (dir) => {
|
|
435
|
+
for (const name of readdirSync(dir).sort()) {
|
|
436
|
+
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
437
|
+
const p = join(dir, name);
|
|
438
|
+
if (statSync(p).isDirectory()) walk(p); else found.push(p);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
walk(root);
|
|
442
|
+
return found;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function pack() {
|
|
446
|
+
const given = positional[0];
|
|
447
|
+
const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
448
|
+
if (!folder || !existsSync(join(folder, 'manifest.json'))) fail(given ? `No manifest.json in ${given}.` : `No manifest.json here or in ${BUILD_DIRS.slice(1).join(', ')}. Build first, or name the folder: toolaby pack <folder>.`);
|
|
449
|
+
let manifest;
|
|
450
|
+
try { manifest = JSON.parse(readFileSync(join(folder, 'manifest.json'), 'utf8')); } catch (e) { fail(`manifest.json does not parse: ${e.message}`); }
|
|
451
|
+
if (!manifest.version) fail('manifest.json has no "version".');
|
|
452
|
+
const files = filesIn(folder);
|
|
453
|
+
const problems = [];
|
|
454
|
+
|
|
455
|
+
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
456
|
+
const modes = new Set(); const sites = new Set();
|
|
457
|
+
for (const f of files) {
|
|
458
|
+
if (!/\.(m?js|html|json)$/.test(f)) continue;
|
|
459
|
+
for (const [k, mode] of readFileSync(f, 'utf8').matchAll(TOOL_KEY)) {
|
|
460
|
+
modes.add(mode);
|
|
461
|
+
try { const facts = JSON.parse(Buffer.from(k.split('_')[2], 'base64url').toString('utf8')); if (typeof facts.s === 'string') sites.add(facts.s.replace(/\/$/, '')); } catch { /* not a key after all */ }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development Wall'}. Run \`npx toolaby wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
|
|
465
|
+
if (modes.size === 0) out('\n No tool key found in the build: it is not wired to the Wall, or the key is somewhere this cannot read. Packing anyway.');
|
|
466
|
+
|
|
467
|
+
// Addresses of this machine have no place in a store build.
|
|
468
|
+
const local = [...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])].filter((m) => typeof m === 'string' && LOCAL.test(m));
|
|
469
|
+
if (local.length && !flag('--allow-local')) problems.push(`The manifest points at this machine: ${local.join(', ')}. A store build has no localhost in host_permissions or externally_connectable. (--allow-local packs it anyway.)`);
|
|
470
|
+
|
|
471
|
+
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
472
|
+
for (const site of sites) {
|
|
473
|
+
const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
|
|
474
|
+
if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${site}/* in both host_permissions and externally_connectable: sign-in and the purchase would not reach the extension. \`npx toolaby wire <tool-id> --live --force\` adds them.`);
|
|
475
|
+
}
|
|
476
|
+
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
477
|
+
|
|
478
|
+
const hadKey = typeof manifest.key === 'string';
|
|
479
|
+
const { key: _key, ...forStore } = manifest;
|
|
480
|
+
const entries = files.map((f) => {
|
|
481
|
+
const name = relative(folder, f).split('\\').join('/');
|
|
482
|
+
return [name, name === 'manifest.json' ? Buffer.from(JSON.stringify(forStore, null, 2) + '\n') : readFileSync(f)];
|
|
483
|
+
});
|
|
484
|
+
// Named after the extension, or after its folder when the name is a __MSG_…__ placeholder.
|
|
485
|
+
const named = typeof manifest.name === 'string' && !manifest.name.startsWith('__MSG_') ? manifest.name : basename(folder);
|
|
486
|
+
const safe = named.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'extension';
|
|
487
|
+
const target = resolve(opt('--out') ?? `${safe}-${manifest.version}.zip`);
|
|
488
|
+
writeFileSync(target, zipOf(entries));
|
|
489
|
+
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
490
|
+
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
491
|
+
if (hadKey) out(' The manifest\'s "key" is left out: the store keeps the id it gives the item. Your unpacked copy keeps the key and its own id; the Wall admits both once the listing\'s address is on the tool.');
|
|
492
|
+
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── mcp and check: the Wall for coding agents ──────────────────────────────────────────────────────────────────────
|
|
496
|
+
function mcp() {
|
|
497
|
+
// Every word to stdout is a protocol message here: the session is the stored one, never a sign-in on the spot.
|
|
498
|
+
runMcp({ version: VERSION, side, wall, token: storedToken, api: (path, o = {}) => api(path, o) });
|
|
499
|
+
return new Promise(() => {}); // until stdin closes
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function check() {
|
|
503
|
+
const passed = await runCheck({ dir: opt('--into') ?? positional[0] ?? '.', out, api: (path, o = {}) => api(path, o), token: storedToken, detectProject, wireManifest, listTree, readJson, browser: flag('--browser') });
|
|
504
|
+
if (!passed) process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
|
|
367
507
|
function help() {
|
|
368
508
|
out(`
|
|
369
509
|
toolaby — the Toolaby command line (${VERSION})
|
|
@@ -375,6 +515,12 @@ function help() {
|
|
|
375
515
|
toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
|
|
376
516
|
--surfaces both|popup|side-panel|none what it opens (default: both)
|
|
377
517
|
toolaby tools your tools, with their ids
|
|
518
|
+
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
519
|
+
refuses a Test key or localhost inside --out <file.zip>
|
|
520
|
+
toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
521
|
+
--browser loads it in Chromium (needs Playwright in the project)
|
|
522
|
+
toolaby mcp the Wall for coding agents, an MCP server on stdio:
|
|
523
|
+
claude mcp add toolaby -- npx -y toolaby@latest mcp
|
|
378
524
|
toolaby webhooks events the six events, with an example of each
|
|
379
525
|
toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
|
|
380
526
|
toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
|
|
@@ -385,7 +531,7 @@ function help() {
|
|
|
385
531
|
`);
|
|
386
532
|
}
|
|
387
533
|
|
|
388
|
-
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
534
|
+
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, check, mcp, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
389
535
|
const run = commands[command];
|
|
390
536
|
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
391
537
|
Promise.resolve().then(run).catch((e) => fail(e.message));
|
package/lib/check.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `toolaby check`: whether the extension in a folder is wired right, in words an agent can act on. The files the Wall
|
|
2
|
+
// writes, the tool key and the tool it names, the manifest against the platform's own wiring rules and its `key`, the
|
|
3
|
+
// client's freshness, the feature keys the code gates on against the tool's, the paywall's page, whether a copy has
|
|
4
|
+
// checked in and whether the key is current, and — with --browser, where Playwright is installed — the extension
|
|
5
|
+
// loaded in Chromium, its background answering and its own pages opening without an error.
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, relative, resolve } from 'node:path';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
|
|
11
|
+
const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
|
|
12
|
+
const CODE = /\.(m?js|jsx|ts|tsx|html|vue|svelte)$/;
|
|
13
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.output', '.wxt', '.plasmo', '.next', 'coverage']);
|
|
14
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
15
|
+
|
|
16
|
+
function decodeKey(key) {
|
|
17
|
+
try {
|
|
18
|
+
const facts = JSON.parse(Buffer.from(key.split('_').slice(2).join('_'), 'base64url').toString('utf8'));
|
|
19
|
+
return typeof facts.s === 'string' && typeof facts.t === 'string' ? { site: facts.s.replace(/\/$/, ''), slug: facts.t, x: facts.k?.x, y: facts.k?.y } : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The extension's own source files, not the Wall's and not a build's. */
|
|
24
|
+
function sourceFiles(dir, acc = [], depth = 4) {
|
|
25
|
+
for (const name of readdirSync(dir)) {
|
|
26
|
+
if (SKIP.has(name) || name.startsWith('.')) continue;
|
|
27
|
+
const p = join(dir, name);
|
|
28
|
+
let st; try { st = statSync(p); } catch { continue; }
|
|
29
|
+
if (st.isDirectory()) { if (depth > 0) sourceFiles(p, acc, depth - 1); continue; }
|
|
30
|
+
if (CODE.test(name) && !name.startsWith('toolaby')) acc.push(p);
|
|
31
|
+
}
|
|
32
|
+
return acc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The code without its comments: an example in a comment (the scaffold's background has one) is not a gate. Strings
|
|
37
|
+
* are kept whole, so a `//` inside one is not taken for a comment.
|
|
38
|
+
*/
|
|
39
|
+
export function withoutComments(text) {
|
|
40
|
+
return text.replace(/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*|<!--[\s\S]*?-->/g, (m, str) => (str ? m : ''));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Feature keys the code asks about: gate({ feature: 'x' }) and has('x'), outside comments. */
|
|
44
|
+
export function featuresUsed(source) {
|
|
45
|
+
const text = withoutComments(source);
|
|
46
|
+
const keys = new Set();
|
|
47
|
+
for (const m of text.matchAll(/gate\(\s*\{[^}]*?feature\s*:\s*['"`]([a-z0-9][a-z0-9-]*)['"`]/g)) keys.add(m[1]);
|
|
48
|
+
for (const m of text.matchAll(/\.has\(\s*['"`]([a-z0-9][a-z0-9-]*)['"`]\s*\)/g)) keys.add(m[1]);
|
|
49
|
+
for (const m of text.matchAll(/showPaywall\(\s*\{[^}]*?feature\s*:\s*['"`]([a-z0-9][a-z0-9-]*)['"`]/g)) keys.add(m[1]);
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runCheck({ dir: given, out, api, token, detectProject, wireManifest, listTree, readJson, browser }) {
|
|
54
|
+
const dir = resolve(given ?? '.');
|
|
55
|
+
const results = [];
|
|
56
|
+
const ok = (s) => { results.push('ok'); out(` ✓ ${s}`); };
|
|
57
|
+
const warn = (s) => { results.push('warn'); out(` ! ${s}`); };
|
|
58
|
+
const bad = (s) => { results.push('bad'); out(` ✗ ${s}`); };
|
|
59
|
+
out(`\n Checking ${dir}\n`);
|
|
60
|
+
|
|
61
|
+
// 1. What the folder is.
|
|
62
|
+
if (!existsSync(dir)) { bad(`${dir} does not exist.`); return false; }
|
|
63
|
+
const tree = listTree(dir, 3);
|
|
64
|
+
const has = (p) => tree.has(p.replace(/\/$/, ''));
|
|
65
|
+
const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
|
|
66
|
+
if (has('manifest.json') && !manifest) { bad('manifest.json is not valid JSON.'); return false; }
|
|
67
|
+
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
68
|
+
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readFileSync(join(dir, configSource), 'utf8') : null, site: 'https://example.invalid', tree });
|
|
69
|
+
if (project.kind === 'unknown') { bad(`Not an extension project: no manifest.json at the top, and not WXT, CRXJS or Plasmo${project.foundManifest ? ` (there is a manifest at ${project.foundManifest}: run this with --into ${project.foundManifest.replace(/\/?manifest\.json$/, '') || '.'})` : ''}.`); return false; }
|
|
70
|
+
ok(project.kind === 'plain' ? 'A plain extension: manifest.json at the top.' : `A ${project.label} project.`);
|
|
71
|
+
|
|
72
|
+
// 2. The Wall's files.
|
|
73
|
+
const at = (f) => join(dir, `${project.codeDir}${f}`);
|
|
74
|
+
const missing = ['toolaby.js', 'toolaby.config.js'].filter((f) => !existsSync(at(f)));
|
|
75
|
+
if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx toolaby wire <tool-id>\` here.`); return false; }
|
|
76
|
+
ok(`The Wall's files are in ${project.codeDir || 'the top folder'}.`);
|
|
77
|
+
|
|
78
|
+
// 3. The tool key, and the tool it names.
|
|
79
|
+
const keyText = readFileSync(at('toolaby.config.js'), 'utf8').match(KEY)?.[0];
|
|
80
|
+
const key = keyText ? decodeKey(keyText) : null;
|
|
81
|
+
if (!key) { bad('toolaby.config.js holds no tool key. Run `npx toolaby upgrade <tool-id>` here.'); return false; }
|
|
82
|
+
const mode = keyText.split('_')[1];
|
|
83
|
+
ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development Wall'} (${key.site}).`);
|
|
84
|
+
const pub = await fetch(`${key.site}/api/tools/${encodeURIComponent(key.slug)}`).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) })).catch((e) => ({ status: 0, json: null, error: e.message }));
|
|
85
|
+
const tool = pub.status === 200 ? pub.json : null;
|
|
86
|
+
if (!tool) bad(pub.status === 0 ? `The Wall at ${key.site} could not be reached.` : pub.status === 404 ? `The Wall does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx toolaby tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
|
|
87
|
+
else {
|
|
88
|
+
ok(`The Wall knows it: ${tool.name}.`);
|
|
89
|
+
const jwk = tool.publicKeyJwk ?? {};
|
|
90
|
+
if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx toolaby upgrade <tool-id>` writes it in.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 4. The manifest, against the platform's own wiring rules: what wiring it again would add.
|
|
94
|
+
const configText = readFileSync(at('toolaby.config.js'), 'utf8');
|
|
95
|
+
if (project.manifestFile && manifest) {
|
|
96
|
+
const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
|
|
97
|
+
const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
|
|
98
|
+
if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
99
|
+
else ok('The manifest has the permissions and host permissions the Wall needs.');
|
|
100
|
+
// The identity: without the `key`, each copy's id follows its folder, and the tool key opens for the first to connect only.
|
|
101
|
+
if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and the tool key opens only for the first copy to connect. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
102
|
+
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}/* in host_permissions and externally_connectable.`);
|
|
103
|
+
|
|
104
|
+
// 5. The client, as fresh as the Wall's.
|
|
105
|
+
const client = await fetch(`${key.site}/client/toolaby.js`).then((r) => (r.ok ? r.text() : null)).catch(() => null);
|
|
106
|
+
if (client === null) warn('The Wall\'s current client could not be read to compare.');
|
|
107
|
+
else if (sha(client) !== sha(readFileSync(at('toolaby.js'), 'utf8'))) warn(`toolaby.js is not the Wall's current client. \`npx toolaby upgrade ${key.slug}\` replaces it (your own files stay).`);
|
|
108
|
+
else ok('toolaby.js is the Wall\'s current client.');
|
|
109
|
+
|
|
110
|
+
// 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
|
|
111
|
+
const sources = sourceFiles(dir).map((f) => readFileSync(f, 'utf8'));
|
|
112
|
+
if (project.manifestFile && manifest && sources.some((s) => /\bshowPaywall\s*\(/.test(withoutComments(s)))) {
|
|
113
|
+
const pages = [manifest.action?.default_popup ? 'toolaby-popup.html' : null, (manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel) ? 'toolaby-sidepanel.html' : null].filter(Boolean);
|
|
114
|
+
const absent = pages.filter((p) => !existsSync(join(dir, `${project.popupDir}${p}`)));
|
|
115
|
+
if (absent.length) warn(`The code calls toolaby.showPaywall(), which opens ${absent.join(' and ')}, and the extension has no such page: the paywall opens the buyer page in a new tab instead. \`npx toolaby upgrade ${key.slug}\` writes ${absent.length > 1 ? 'them' : 'it'}; your pages stay in front.`);
|
|
116
|
+
}
|
|
117
|
+
if (tool) {
|
|
118
|
+
const used = new Set();
|
|
119
|
+
for (const s of sources) for (const k of featuresUsed(s)) used.add(k);
|
|
120
|
+
const known = new Set((tool.features ?? []).map((f) => f.key));
|
|
121
|
+
const unknown = [...used].filter((k) => !known.has(k));
|
|
122
|
+
if (unknown.length) bad(`The code gates on ${unknown.map((k) => `"${k}"`).join(', ')}, which ${unknown.length > 1 ? 'the tool does not have' : 'is not a feature of the tool'}: every buyer would be refused. Add ${unknown.length > 1 ? 'them' : 'it'} (the MCP tool toolaby_set_features, or the tool's Pricing page).`);
|
|
123
|
+
else if (used.size) ok(`The code gates on ${[...used].map((k) => `"${k}"`).join(', ')}: ${used.size > 1 ? 'all features' : 'a feature'} of the tool.`);
|
|
124
|
+
const unsold = [...used].filter((k) => known.has(k) && !(tool.plans ?? []).some((p) => (p.features ?? []).includes(k)) && !(tool.features ?? []).find((f) => f.key === k)?.free);
|
|
125
|
+
if (unsold.length) warn(`No plan unlocks ${unsold.map((k) => `"${k}"`).join(', ')} yet: buyers are offered nothing for ${unsold.length > 1 ? 'them' : 'it'}. Say which plans do (toolaby_set_features with plans, or the Pricing page).`);
|
|
126
|
+
if (tool.access !== 'open' && !(tool.plans ?? []).length) warn('The Free plan does not hold everything, and there is no plan to buy. Add one (toolaby_add_plan, or Pricing).');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 7. A copy that checked in: what the dashboard's Set up page waits for.
|
|
130
|
+
if (tool && token()) {
|
|
131
|
+
const facts = await api(`/api/cli/tools/${encodeURIComponent(key.slug)}`).catch(() => null);
|
|
132
|
+
const seen = facts?.ok ? facts.json?.extension?.checkedInAt : undefined;
|
|
133
|
+
if (seen) ok(`A copy of the extension checked in (${new Date(seen).toISOString().slice(0, 16).replace('T', ' ')} UTC).`);
|
|
134
|
+
else if (facts?.ok) warn('No copy has checked in yet: load it at chrome://extensions and open it once.');
|
|
135
|
+
// The key carries what the Free plan holds and the kind of plan sold: a change since it was written leaves it behind.
|
|
136
|
+
const current = facts?.ok ? facts.json?.toolKey : null;
|
|
137
|
+
if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than the Wall's: what the Free plan holds, the plans or the signing key changed since it was written. \`npx toolaby upgrade ${key.slug}\` writes the current one.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 8. In a browser: the background answers.
|
|
141
|
+
if (browser) await browserCheck({ dir, project, manifest, ok, warn, bad });
|
|
142
|
+
|
|
143
|
+
const n = (k) => results.filter((r) => r === k).length;
|
|
144
|
+
out(`\n ${n('bad') ? `${n('bad')} to fix` : 'Nothing to fix'}${n('warn') ? `, ${n('warn')} to look at` : ''}.\n`);
|
|
145
|
+
return n('bad') === 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
149
|
+
let chromium;
|
|
150
|
+
// Playwright's entry is CommonJS: imported, its exports are the module's default.
|
|
151
|
+
try { const pw = await import(createRequire(join(dir, 'package.json')).resolve('playwright')); chromium = pw.chromium ?? pw.default?.chromium; if (!chromium) throw new Error('no chromium'); }
|
|
152
|
+
catch { warn(`--browser needs Playwright in this project: ${existsSync(join(dir, 'package.json')) ? '' : '`npm init -y` (there is no package.json), then '}\`npm i -D playwright && npx playwright install chromium\`.`); return; }
|
|
153
|
+
const built = project.kind === 'plain' ? dir : ['.output/chrome-mv3', 'dist', 'build/chrome-mv3-dev', 'build/chrome-mv3-prod', 'build'].map((d) => join(dir, d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
154
|
+
if (!built) { warn(`No built extension to load: build first (${project.label}).`); return; }
|
|
155
|
+
const shipped = readJsonSafe(join(built, 'manifest.json')) ?? manifest;
|
|
156
|
+
const ctx = await chromium.launchPersistentContext('', { channel: 'chromium', headless: true, args: [`--disable-extensions-except=${built}`, `--load-extension=${built}`] }).catch((e) => { bad(`Chromium did not start: ${e.message}`); return null; });
|
|
157
|
+
if (!ctx) return;
|
|
158
|
+
try {
|
|
159
|
+
const worker = ctx.serviceWorkers()[0] ?? (await ctx.waitForEvent('serviceworker', { timeout: 15_000 }).catch(() => null));
|
|
160
|
+
if (!worker) { bad('The extension\'s service worker did not start: its code threw while loading. Open chrome://extensions → Errors.'); return; }
|
|
161
|
+
const id = new URL(worker.url()).host;
|
|
162
|
+
ok(`Loaded in Chromium as ${id} (${relative(dir, built) || '.'}).`);
|
|
163
|
+
const pagePath = shipped?.action?.default_popup ?? shipped?.side_panel?.default_path ?? null;
|
|
164
|
+
const page = await ctx.newPage();
|
|
165
|
+
await page.goto(pagePath ? `chrome-extension://${id}/${pagePath}` : `chrome-extension://${id}/manifest.json`);
|
|
166
|
+
const answer = await page.evaluate(() => Promise.race([
|
|
167
|
+
chrome.runtime.sendMessage({ action: 'getLicenseStatus' }).then((r) => r ?? null),
|
|
168
|
+
new Promise((r) => setTimeout(() => r('timeout'), 8000)),
|
|
169
|
+
])).catch((e) => `error: ${e.message}`);
|
|
170
|
+
if (answer && answer !== 'timeout' && !String(answer).startsWith('error')) ok('The background answers the Wall\'s client: its listeners registered.');
|
|
171
|
+
else bad(`The background did not answer (${answer ?? 'nothing'}): its module threw while loading, so none of its listeners registered.`);
|
|
172
|
+
await page.close().catch(() => {});
|
|
173
|
+
// The extension's own pages, each opened in a tab: one whose script throws is a popup that opens blank.
|
|
174
|
+
const own = [shipped?.action?.default_popup, shipped?.side_panel?.default_path].filter((p, i, all) => typeof p === 'string' && p && all.indexOf(p) === i);
|
|
175
|
+
for (const p of own) {
|
|
176
|
+
const tab = await ctx.newPage();
|
|
177
|
+
const errors = [];
|
|
178
|
+
tab.on('pageerror', (e) => errors.push(e.message));
|
|
179
|
+
await tab.goto(`chrome-extension://${id}/${p}`).catch((e) => errors.push(e.message));
|
|
180
|
+
await tab.waitForTimeout(1500);
|
|
181
|
+
await tab.close().catch(() => {});
|
|
182
|
+
if (errors.length) bad(`${p} threw when it opened: ${errors[0]}`);
|
|
183
|
+
else ok(`${p} opens without an error.`);
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
await ctx.close().catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readJsonSafe(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }
|
package/lib/manifest.js
CHANGED
|
@@ -12,11 +12,28 @@
|
|
|
12
12
|
* that fixes its id, which the tool key is bound to (identity.ts) — unless
|
|
13
13
|
* the manifest has one of its own, which is then the identity the platform
|
|
14
14
|
* adopts. Pure: given the manifest and what is on disk, it says what to
|
|
15
|
-
* write.
|
|
15
|
+
* write. An extension whose own pages draw the panel (drawsItself) keeps
|
|
16
|
+
* them in front: nothing of the Wall's is put there.
|
|
16
17
|
*/
|
|
17
18
|
export const WALL_POPUP = 'toolaby-popup.html';
|
|
18
19
|
export const WALL_SIDE_PANEL = 'toolaby-sidepanel.html';
|
|
19
20
|
export const WORKER_LINES = "import { toolaby } from './toolaby.js';\n\ntoolaby.startBackground();\n";
|
|
21
|
+
/**
|
|
22
|
+
* Whether an earlier wiring left the extension's own pages drawing the panel: a configuration that says '' for both
|
|
23
|
+
* pages while the manifest names none of the Wall's. That is what `create` makes, and the "your popup first" shape
|
|
24
|
+
* (docs/gating); a wiring keeps it rather than putting the Wall's page back in front. A first wiring (no configuration
|
|
25
|
+
* yet) puts the Wall's in front.
|
|
26
|
+
*/
|
|
27
|
+
export function drawsItself(manifest, oldConfig) {
|
|
28
|
+
if (!oldConfig)
|
|
29
|
+
return false;
|
|
30
|
+
const popup = oldConfig.match(/appPopup:\s*'([^']*)'/);
|
|
31
|
+
const side = oldConfig.match(/appSidePanel:\s*'([^']*)'/);
|
|
32
|
+
if (!popup || popup[1] !== '' || (side && side[1] !== ''))
|
|
33
|
+
return false;
|
|
34
|
+
const pages = [manifest?.action?.default_popup, manifest?.side_panel?.default_path, manifest?.sidebar_action?.default_panel];
|
|
35
|
+
return !pages.some((p) => p === WALL_POPUP || p === WALL_SIDE_PANEL);
|
|
36
|
+
}
|
|
20
37
|
export function wireManifest(input, o) {
|
|
21
38
|
const manifest = JSON.parse(JSON.stringify(input));
|
|
22
39
|
const did = [];
|
|
@@ -44,7 +61,8 @@ export function wireManifest(input, o) {
|
|
|
44
61
|
}
|
|
45
62
|
// The extension's own popup: the manifest's, or the one remembered when the manifest already names the Wall's.
|
|
46
63
|
const declared = typeof manifest.action?.default_popup === 'string' ? manifest.action.default_popup : null;
|
|
47
|
-
const
|
|
64
|
+
const ownPopup = declared && declared !== WALL_POPUP ? declared : declared === WALL_POPUP ? (o.remembered || null) : null;
|
|
65
|
+
const appPopup = o.ownInFront ? null : ownPopup;
|
|
48
66
|
if (appPopup && declared !== WALL_POPUP) {
|
|
49
67
|
manifest.action = { ...manifest.action, default_popup: WALL_POPUP };
|
|
50
68
|
did.push(`the Wall's popup in front of ${appPopup}: it opens on to yours while the device may, and shows the panel when it may not`);
|
|
@@ -55,15 +73,17 @@ export function wireManifest(input, o) {
|
|
|
55
73
|
const declaredPanel = typeof manifest.side_panel?.default_path === 'string' ? manifest.side_panel.default_path : null;
|
|
56
74
|
const declaredSidebar = typeof manifest.sidebar_action?.default_panel === 'string' ? manifest.sidebar_action.default_panel : null;
|
|
57
75
|
const ours = declaredPanel === WALL_SIDE_PANEL || declaredSidebar === WALL_SIDE_PANEL;
|
|
58
|
-
const
|
|
59
|
-
|
|
76
|
+
const ownSidePanel = theirs(declaredPanel) ?? theirs(declaredSidebar) ?? (ours ? o.rememberedSidePanel || null : null);
|
|
77
|
+
const appSidePanel = o.ownInFront ? null : ownSidePanel;
|
|
78
|
+
if (o.ownInFront) { /* the extension's own side panel stays in front, as its popup does */ }
|
|
79
|
+
else if (theirs(declaredPanel)) {
|
|
60
80
|
manifest.side_panel = { ...manifest.side_panel, default_path: WALL_SIDE_PANEL };
|
|
61
81
|
if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('sidePanel')))
|
|
62
82
|
manifest.permissions = add(manifest.permissions, 'sidePanel');
|
|
63
83
|
did.push(`the Wall's side panel in front of ${declaredPanel}: the same as the popup, at the panel's width`);
|
|
64
84
|
}
|
|
65
85
|
// Firefox's sidebar, when it names the same page — one that names another is left alone rather than pointed elsewhere.
|
|
66
|
-
if (theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
|
|
86
|
+
if (!o.ownInFront && theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
|
|
67
87
|
manifest.sidebar_action = { ...manifest.sidebar_action, default_panel: WALL_SIDE_PANEL };
|
|
68
88
|
did.push(`the Wall's side panel in front of ${declaredSidebar} in Firefox's sidebar_action too`);
|
|
69
89
|
}
|
|
@@ -78,5 +98,5 @@ export function wireManifest(input, o) {
|
|
|
78
98
|
else {
|
|
79
99
|
ownWorker = { file: named, classic: manifest.background?.type !== 'module' };
|
|
80
100
|
}
|
|
81
|
-
return { manifest, did, writeWorker, ownWorker, appPopup, appSidePanel, manifestKey };
|
|
101
|
+
return { manifest, did, writeWorker, ownWorker, appPopup, appSidePanel, manifestKey, ownPopup, ownSidePanel };
|
|
82
102
|
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// `toolaby mcp`: the Wall for coding agents, as a Model Context Protocol server on stdio.
|
|
2
|
+
//
|
|
3
|
+
// An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby mcp` —
|
|
4
|
+
// and gets tools to read and change the developer's tools on the Wall: what the Free plan holds, features, plans,
|
|
5
|
+
// grants, and the docs. It acts with the session `toolaby login` stored, on Test unless started with --live, through
|
|
6
|
+
// the same routes and the same checks as the dashboard.
|
|
7
|
+
//
|
|
8
|
+
// The protocol is the part of MCP a tools server needs, written here: newline-delimited JSON-RPC 2.0 on stdin and
|
|
9
|
+
// stdout — initialize, ping, tools/list, tools/call. The official SDK brings seventeen packages (an HTTP server among
|
|
10
|
+
// them) to a command line that has none and that the Wall also serves as one tarball; this is the stdio transport and
|
|
11
|
+
// the tools capability exactly as the specification words them. Nothing but protocol messages goes to stdout.
|
|
12
|
+
|
|
13
|
+
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
14
|
+
|
|
15
|
+
/** The tools, with JSON Schema for their arguments, and what each calls on the Wall. */
|
|
16
|
+
function toolsFor(side) {
|
|
17
|
+
const tool = { type: 'string', description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
name: 'toolaby_whoami',
|
|
21
|
+
title: 'Who is signed in',
|
|
22
|
+
description: `The developer signed in to the ${side} Wall, and their workspaces.`,
|
|
23
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
24
|
+
run: (_a, api) => api('/api/cli/me'),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'toolaby_list_tools',
|
|
28
|
+
title: 'List tools',
|
|
29
|
+
description: `Every tool in the developer's workspaces on the ${side} Wall, with the id the other tools take.`,
|
|
30
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
31
|
+
run: (_a, api) => api('/api/cli/tools'),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'toolaby_get_tool',
|
|
35
|
+
title: 'Read a tool',
|
|
36
|
+
description: `One tool on the ${side} Wall: what the Free plan holds (freePlan.holds, in a sentence), its features, its plans with what each unlocks, its tool key, and its extension: identityId is the id of every copy whose manifest has the tool's key (such a copy needs no binding, so boundId stays null), checkedInAt says whether one has checked in, inUse which builds. Then the addresses of its pages.`,
|
|
37
|
+
inputSchema: { type: 'object', properties: { tool }, required: ['tool'], additionalProperties: false },
|
|
38
|
+
run: (a, api) => api(`/api/cli/tools/${encodeURIComponent(a.tool)}`),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'toolaby_create_tool',
|
|
42
|
+
title: 'Create a tool',
|
|
43
|
+
description: `A new tool on the ${side} Wall. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx toolaby create <id>\` for a new one, \`npx toolaby wire <id>\` for an existing one.`,
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
name: { type: 'string', description: 'What buyers see, like "Word Counter".' },
|
|
48
|
+
id: { type: 'string', description: 'Optional: the id to use within the workspace, lower-case letters, digits and hyphens. Default: from the name.' },
|
|
49
|
+
tagline: { type: 'string', description: 'Optional: one line under the name on the checkout page.' },
|
|
50
|
+
workspace: { type: 'string', description: 'The workspace slug, when the developer has more than one.' },
|
|
51
|
+
},
|
|
52
|
+
required: ['name'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
run: (a, api) => api('/api/cli/tools', { method: 'POST', body: a }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'toolaby_set_access',
|
|
59
|
+
title: 'Set what the Free plan holds',
|
|
60
|
+
description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx toolaby upgrade <id>` then writes the new one.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
tool,
|
|
65
|
+
access: { type: 'string', enum: ['open', 'free_uses', 'paid'] },
|
|
66
|
+
freeUses: { type: 'integer', minimum: 1, maximum: 1000, description: 'With "free_uses": how many per device.' },
|
|
67
|
+
signInRequired: { type: 'boolean' },
|
|
68
|
+
},
|
|
69
|
+
required: ['tool', 'access'],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/access`, { method: 'POST', body: rest }),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'toolaby_set_features',
|
|
76
|
+
title: 'Set features, and the plans that unlock them',
|
|
77
|
+
description: 'The tool\'s whole feature list (it replaces the current one: send the features it has too), and which plans unlock which. A feature\'s key is what the extension passes to gate({ feature }) and has(). A feature marked free is on the Free plan too; one that is not free and that no plan unlocks is refused to everyone. The plans must exist first (toolaby_add_plan); a plan not named in `plans` keeps what it unlocks.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
tool,
|
|
82
|
+
features: {
|
|
83
|
+
type: 'array',
|
|
84
|
+
items: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
key: { type: 'string', description: 'Lower-case letters, digits and hyphens, like "export".' },
|
|
88
|
+
name: { type: 'string', description: 'What buyers see, like "Export to CSV".' },
|
|
89
|
+
blurb: { type: 'string', description: 'Optional: one sentence the paywall shows.' },
|
|
90
|
+
free: { type: 'boolean', description: 'On the Free plan too.' },
|
|
91
|
+
},
|
|
92
|
+
required: ['key', 'name'],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
plans: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
description: 'Optional: for each plan id (see toolaby_get_tool), the feature keys it unlocks.',
|
|
99
|
+
items: { type: 'object', properties: { id: { type: 'string' }, features: { type: 'array', items: { type: 'string' } } }, required: ['id', 'features'], additionalProperties: false },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ['tool', 'features'],
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
},
|
|
105
|
+
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/features`, { method: 'POST', body: rest }),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'toolaby_add_plan',
|
|
109
|
+
title: 'Add a plan',
|
|
110
|
+
description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx toolaby upgrade <id>\` then writes the new one.`,
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
tool,
|
|
115
|
+
billing: { type: 'string', enum: ['one_time', 'subscription'] },
|
|
116
|
+
interval: { type: 'string', enum: ['month', 'year'], description: 'Subscriptions only.' },
|
|
117
|
+
amount: { type: 'number', minimum: 0.5, description: 'In the currency\'s main unit, like 9.99.' },
|
|
118
|
+
currency: { type: 'string', description: 'Three letters, like "usd" or "eur".' },
|
|
119
|
+
name: { type: 'string', description: 'Optional: the plan\'s name, like "Pro". Two plans with one name unlock the same features.' },
|
|
120
|
+
trialDays: { type: 'integer', minimum: 0, maximum: 365, description: 'Subscriptions only: a free trial.' },
|
|
121
|
+
devices: { type: 'integer', minimum: 1, maximum: 10, description: 'Optional: devices one buyer may use it on at once.' },
|
|
122
|
+
perSeat: { type: 'boolean', description: 'Optional: sold per seat, to teams.' },
|
|
123
|
+
},
|
|
124
|
+
required: ['tool', 'billing', 'amount', 'currency'],
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
},
|
|
127
|
+
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/plans`, { method: 'POST', body: rest }),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'toolaby_grant',
|
|
131
|
+
title: 'Grant access',
|
|
132
|
+
description: 'Access to the tool without paying: to test a paid feature on Test, or for a reviewer. An address with no account gets one; the grant applies once that person signs in to the extension with it (the link arrives by email), so testing a paid feature this way needs a person for a moment. For a number of days, or until revoked. On the Free plan a workspace holds 100 grants and other passes at once.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: { tool, email: { type: 'string' }, days: { type: 'integer', minimum: 1, maximum: 3650, description: 'Optional: how long. Default: until revoked.' } },
|
|
136
|
+
required: ['tool', 'email'],
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
},
|
|
139
|
+
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/grants`, { method: 'POST', body: rest }),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'toolaby_docs',
|
|
143
|
+
title: 'Read the docs',
|
|
144
|
+
description: 'The Wall\'s docs as Markdown. Without a page: the index, every page with what it covers. With a page (like "gating", "pricing", "reference/client"): that page.',
|
|
145
|
+
inputSchema: { type: 'object', properties: { page: { type: 'string' } }, additionalProperties: false },
|
|
146
|
+
run: (a, _api, docs) => docs(a.page),
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a call answers, as MCP content: the Wall's JSON as text, and as structured content. */
|
|
152
|
+
function resultOf(res) {
|
|
153
|
+
const body = res.json ?? {};
|
|
154
|
+
if (res.ok) return { content: [{ type: 'text', text: typeof res.text === 'string' ? res.text : JSON.stringify(body, null, 2) }], ...(typeof res.text === 'string' ? {} : { structuredContent: body }) };
|
|
155
|
+
const why = body.message ?? body.error ?? `The Wall answered HTTP ${res.status}.`;
|
|
156
|
+
return { content: [{ type: 'text', text: why }], isError: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runs the server until stdin closes. `api(path, { method, body })` answers `{ ok, status, json }` with the stored
|
|
161
|
+
* session; `token()` says whether there is one.
|
|
162
|
+
*/
|
|
163
|
+
export function runMcp({ version, side, wall, api, token }) {
|
|
164
|
+
const tools = toolsFor(side);
|
|
165
|
+
const send = (msg) => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
|
|
166
|
+
const log = (s) => process.stderr.write(`[toolaby mcp] ${s}\n`);
|
|
167
|
+
const docs = async (page) => {
|
|
168
|
+
const clean = typeof page === 'string' ? page.replace(/^\/+|\.md$|\/+$/g, '').replace(/^docs\//, '') : '';
|
|
169
|
+
if (clean && !/^[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(clean)) return { ok: false, status: 400, json: { message: 'A page is a path like "gating" or "reference/client".' } };
|
|
170
|
+
const url = clean ? `${wall}/docs/${clean}.md` : `${wall}/llms.txt`;
|
|
171
|
+
const res = await fetch(url).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
return res.ok ? { ok: true, status: res.status, text } : { ok: false, status: res.status, json: { message: res.status === 404 ? `No docs page "${clean}". Call toolaby_docs without a page for the index.` : `The docs answered HTTP ${res.status}.` } };
|
|
174
|
+
};
|
|
175
|
+
const call = async (name, args) => {
|
|
176
|
+
const t = tools.find((x) => x.name === name);
|
|
177
|
+
if (!t) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
|
|
178
|
+
if (name !== 'toolaby_docs' && !token()) {
|
|
179
|
+
return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx toolaby login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return { result: resultOf(await t.run(args ?? {}, api, docs)) };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { result: { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true } };
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const handle = async (msg) => {
|
|
188
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
189
|
+
if (msg && 'id' in msg && !('method' in msg)) return; // a response to nothing we asked: ignored
|
|
190
|
+
return send({ id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request' } });
|
|
191
|
+
}
|
|
192
|
+
const isRequest = 'id' in msg;
|
|
193
|
+
switch (msg.method) {
|
|
194
|
+
case 'initialize': {
|
|
195
|
+
const asked = msg.params?.protocolVersion;
|
|
196
|
+
return send({ id: msg.id, result: {
|
|
197
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
198
|
+
capabilities: { tools: { listChanged: false } },
|
|
199
|
+
serverInfo: { name: 'toolaby', title: 'Toolaby Wall', version },
|
|
200
|
+
instructions: `The Toolaby Wall (${side}, ${wall}): accounts, licences, subscriptions and the paywall for Chrome extensions. Read a tool with toolaby_get_tool before changing it; the extension gates paid work with toolaby.gate({ feature }) and each feature key must exist on the tool (toolaby_set_features). Wire files with the shell: npx toolaby create|wire|upgrade <id>, check with npx toolaby check. toolaby_docs reads the docs.`,
|
|
201
|
+
} });
|
|
202
|
+
}
|
|
203
|
+
case 'notifications/initialized':
|
|
204
|
+
case 'notifications/cancelled':
|
|
205
|
+
return;
|
|
206
|
+
case 'ping':
|
|
207
|
+
return isRequest ? send({ id: msg.id, result: {} }) : undefined;
|
|
208
|
+
case 'tools/list':
|
|
209
|
+
return send({ id: msg.id, result: { tools: tools.map(({ run: _run, ...t }) => t) } });
|
|
210
|
+
case 'tools/call': {
|
|
211
|
+
const out = await call(msg.params?.name, msg.params?.arguments);
|
|
212
|
+
return send({ id: msg.id, ...out });
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
return isRequest ? send({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }) : undefined;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx toolaby login`'}`);
|
|
220
|
+
let buffer = '';
|
|
221
|
+
process.stdin.setEncoding('utf8');
|
|
222
|
+
process.stdin.on('data', (chunk) => {
|
|
223
|
+
buffer += chunk;
|
|
224
|
+
let at;
|
|
225
|
+
while ((at = buffer.indexOf('\n')) >= 0) {
|
|
226
|
+
const line = buffer.slice(0, at).trim();
|
|
227
|
+
buffer = buffer.slice(at + 1);
|
|
228
|
+
if (!line) continue;
|
|
229
|
+
let msg;
|
|
230
|
+
try { msg = JSON.parse(line); } catch { send({ id: null, error: { code: -32700, message: 'Parse error' } }); continue; }
|
|
231
|
+
// A batch (the 2025-03-26 revision allowed them): each message on its own.
|
|
232
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) handle(m).catch((e) => log(e instanceof Error ? e.stack ?? e.message : String(e)));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
process.stdin.on('end', () => process.exit(0));
|
|
236
|
+
}
|
package/lib/project.js
CHANGED
|
@@ -9,15 +9,27 @@ export const MANIFEST_HELPER = ['toolaby.manifest.js', 'toolaby.manifest.d.ts'];
|
|
|
9
9
|
export const POPUP_FILES = ['toolaby-popup.html', 'toolaby-popup.js'];
|
|
10
10
|
/** The Wall's side panel: the same page at the panel's width, the same two files. */
|
|
11
11
|
export const SIDE_PANEL_FILES = ['toolaby-sidepanel.html', 'toolaby-sidepanel.js'];
|
|
12
|
+
/** What a coding agent reads, at the project's root: the guide to the wiring, and the skill where Claude Code finds skills (wiring/agents). Rewritten by every wiring. */
|
|
13
|
+
export const AGENT_FILES = [
|
|
14
|
+
{ name: 'TOOLABY.md', path: 'TOOLABY.md' },
|
|
15
|
+
{ name: 'SKILL.md', path: '.claude/skills/toolaby/SKILL.md' },
|
|
16
|
+
];
|
|
17
|
+
/** Written only where the project has none: AGENTS.md, which every coding agent reads, and CLAUDE.md, which reads it for Claude Code. */
|
|
18
|
+
export const AGENT_ENTRY_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
12
19
|
/** What earlier wirings wrote and this one does not: an upgrade takes them away, wherever a wiring put them. */
|
|
13
20
|
export const STALE_FILES = ['toolaby-client.module.js', 'paywall.js', 'paywall.css', 'toolaby-popup.css', 'toolaby-account.js'];
|
|
14
|
-
/**
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Every file a wiring writes, with its path: the code files, and — for a popup or a side panel — the Wall's page for it:
|
|
23
|
+
* in front of the extension's (`appPopup`), or beside it when the extension's own page draws the panel (`own`), where
|
|
24
|
+
* showPaywall() opens it.
|
|
25
|
+
*/
|
|
26
|
+
export const filesToWrite = (p, appPopup, appSidePanel = null, own = {}) => [
|
|
16
27
|
...CODE_FILES.map((f) => `${p.codeDir}${f}`),
|
|
17
28
|
...(p.manifestFile ? [] : MANIFEST_HELPER.map((f) => `${p.codeDir}${f}`)),
|
|
18
|
-
...(appPopup ? POPUP_FILES.map((f) => `${p.popupDir}${f}`) : []),
|
|
19
|
-
...(appSidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
|
|
29
|
+
...(appPopup || own.popup ? POPUP_FILES.map((f) => `${p.popupDir}${f}`) : []),
|
|
30
|
+
...(appSidePanel || own.sidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
|
|
20
31
|
...(p.extraFiles ?? []).map((f) => f.to),
|
|
32
|
+
...AGENT_FILES.map((f) => f.path),
|
|
21
33
|
];
|
|
22
34
|
export const SURFACES = ['both', 'popup', 'side-panel', 'none'];
|
|
23
35
|
export const surfacesOf = (v) => SURFACES.includes(v ?? '') ? v : 'both';
|
|
@@ -34,6 +46,8 @@ export const scaffoldFilesFor = (s, all) => all.filter((f) => (f === 'popup.html
|
|
|
34
46
|
*/
|
|
35
47
|
export function scaffoldManifest(manifest, wrote) {
|
|
36
48
|
const out = { ...manifest };
|
|
49
|
+
if (wrote.key && typeof out.key !== 'string')
|
|
50
|
+
out.key = wrote.key;
|
|
37
51
|
if (wrote.popup === false) {
|
|
38
52
|
const action = { ...out.action };
|
|
39
53
|
delete action.default_popup;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one.",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one; check it, and give coding agents the Wall as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"toolaby": "bin.mjs"
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"lib",
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
14
17
|
"engines": {
|
|
15
18
|
"node": ">=18"
|
|
16
19
|
},
|