toolaby 1.5.0 → 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/bin.mjs +61 -23
- 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/bin.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
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
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)
|
|
9
11
|
// toolaby webhooks events the six events, with an example of each
|
|
10
12
|
// toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
|
|
11
13
|
// toolaby webhooks endpoints the workspace's endpoints
|
|
@@ -33,10 +35,12 @@ import { deflateRawSync } from 'node:zlib';
|
|
|
33
35
|
import { homedir } from 'node:os';
|
|
34
36
|
import { spawn } from 'node:child_process';
|
|
35
37
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
36
|
-
import { wireManifest, WORKER_LINES } from './lib/manifest.js';
|
|
37
|
-
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';
|
|
38
42
|
|
|
39
|
-
const VERSION = '1.
|
|
43
|
+
const VERSION = '1.6.0';
|
|
40
44
|
const CLIENT_ID = 'toolaby-cli';
|
|
41
45
|
const argv = process.argv.slice(2);
|
|
42
46
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -169,14 +173,16 @@ function listTree(dir, depth, prefix = '', acc = new Set()) {
|
|
|
169
173
|
return acc;
|
|
170
174
|
}
|
|
171
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; } };
|
|
172
178
|
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
173
179
|
|
|
174
180
|
/** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
|
|
175
|
-
async function filesFor(slug, scaffold, manifestKey = null) {
|
|
181
|
+
async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
176
182
|
const token = await tokenOrLogin();
|
|
177
|
-
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);
|
|
178
184
|
const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
|
|
179
|
-
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); }
|
|
180
186
|
if (res.status === 404) {
|
|
181
187
|
// Say who the command is: the tool may well exist — in a workspace of another account of theirs.
|
|
182
188
|
const me = await api('/api/cli/me', { token }).catch(() => null);
|
|
@@ -210,17 +216,25 @@ async function wire() {
|
|
|
210
216
|
const oldConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
211
217
|
const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
|
|
212
218
|
const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
|
|
213
|
-
|
|
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;
|
|
214
222
|
const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
|
|
215
223
|
const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
|
|
216
|
-
const targets = filesToWrite(project, appPopup, appSidePanel);
|
|
224
|
+
const targets = filesToWrite(project, appPopup, appSidePanel, { popup: !!wiring?.ownPopup, sidePanel: !!wiring?.ownSidePanel });
|
|
217
225
|
const present = targets.filter((f) => existsSync(join(dir, f)));
|
|
218
226
|
if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
|
|
219
227
|
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
220
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.
|
|
221
|
-
|
|
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');
|
|
222
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}`);
|
|
223
|
-
|
|
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');
|
|
224
238
|
// What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
|
|
225
239
|
const removed = [];
|
|
226
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}`); } }
|
|
@@ -230,17 +244,18 @@ async function wire() {
|
|
|
230
244
|
if (wiring.writeWorker) write(wiring.writeWorker, WORKER_LINES);
|
|
231
245
|
if (wiring.did.length) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
|
|
232
246
|
did.push(...wiring.did);
|
|
233
|
-
if (wiring.ownWorker) ownWorker = wiring.ownWorker;
|
|
247
|
+
if (wiring.ownWorker && !startsPlatform(join(dir, wiring.ownWorker.file))) ownWorker = wiring.ownWorker;
|
|
234
248
|
} else if (project.createWorker) {
|
|
235
249
|
write(project.createWorker.file, project.createWorker.content);
|
|
236
250
|
did.push(`a background entry, ${project.createWorker.file} (it starts the platform)`);
|
|
237
|
-
} else if (project.worker && !project.extraFiles) {
|
|
251
|
+
} else if (project.worker && !project.extraFiles && !startsPlatform(join(dir, project.worker))) {
|
|
238
252
|
// A framework with its own extension point (WXT's module) starts the platform in the background itself.
|
|
239
253
|
ownWorker = { file: project.worker, classic: false };
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
243
|
-
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');
|
|
244
259
|
if (removed.length) out(` Removed: ${removed.join(', ')} — earlier files, now inside toolaby.js.\n`);
|
|
245
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`);
|
|
246
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`);
|
|
@@ -248,6 +263,7 @@ async function wire() {
|
|
|
248
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.`);
|
|
249
264
|
if (project.note) out(` ${project.note}\n`);
|
|
250
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`);
|
|
251
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`);
|
|
252
268
|
}
|
|
253
269
|
|
|
@@ -256,30 +272,36 @@ async function create() {
|
|
|
256
272
|
if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
|
|
257
273
|
const dir = resolve(opt('--dir') ?? slug);
|
|
258
274
|
if (existsSync(dir)) fail(`${dir} already exists`);
|
|
259
|
-
|
|
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);
|
|
260
280
|
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
261
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
|
|
262
282
|
// minimal extension is written by this command too, instead of a manifest naming a file that never arrived.
|
|
263
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'];
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
const
|
|
267
|
-
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
268
|
-
const surfaces = surfacesOf(asked);
|
|
269
|
-
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 : [])];
|
|
270
287
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
271
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}`);
|
|
272
289
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
273
290
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
274
291
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
275
292
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
276
|
-
|
|
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');
|
|
277
295
|
for (const s of [16, 48, 128]) {
|
|
278
296
|
const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
|
|
279
297
|
write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
|
|
280
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]);
|
|
281
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];
|
|
282
|
-
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`);
|
|
283
305
|
}
|
|
284
306
|
|
|
285
307
|
// ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
|
|
@@ -470,6 +492,18 @@ function pack() {
|
|
|
470
492
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
471
493
|
}
|
|
472
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
|
+
|
|
473
507
|
function help() {
|
|
474
508
|
out(`
|
|
475
509
|
toolaby — the Toolaby command line (${VERSION})
|
|
@@ -483,6 +517,10 @@ function help() {
|
|
|
483
517
|
toolaby tools your tools, with their ids
|
|
484
518
|
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
485
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
|
|
486
524
|
toolaby webhooks events the six events, with an example of each
|
|
487
525
|
toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
|
|
488
526
|
toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
|
|
@@ -493,7 +531,7 @@ function help() {
|
|
|
493
531
|
`);
|
|
494
532
|
}
|
|
495
533
|
|
|
496
|
-
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, 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) };
|
|
497
535
|
const run = commands[command];
|
|
498
536
|
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
499
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
|
},
|