toolaby 1.6.1 → 1.6.3
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 +5 -3
- package/bin.mjs +187 -90
- package/lib/check.js +36 -23
- package/lib/mcp.js +44 -12
- package/lib/project.js +5 -3
- package/lib/safe.js +340 -8
- package/package.json +1 -1
package/lib/check.js
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
// client's freshness, the feature keys the code gates on against the tool's, the paywall's page, whether a copy has
|
|
4
4
|
// checked in and whether the key is current, and — with --browser, where Playwright is installed — the extension
|
|
5
5
|
// loaded in Chromium, its background answering and its own pages opening without an error.
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { join, relative, resolve } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { oneLine, keySite, fetchBounded, readText } from './safe.js';
|
|
10
11
|
|
|
11
12
|
const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
|
|
13
|
+
/** A tool id as the Wall makes them (lib/mcp.js has the same): the only thing the check puts after a Wall's address. */
|
|
14
|
+
const TOOL_ID = /^[a-z0-9][a-z0-9-]{1,118}$/;
|
|
12
15
|
const CODE = /\.(m?js|jsx|ts|tsx|html|vue|svelte)$/;
|
|
13
16
|
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.output', '.wxt', '.plasmo', '.next', 'coverage']);
|
|
14
17
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
@@ -65,54 +68,63 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
65
68
|
const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
|
|
66
69
|
if (has('manifest.json') && !manifest) { bad('manifest.json is not valid JSON.'); return false; }
|
|
67
70
|
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
68
|
-
const
|
|
69
|
-
|
|
71
|
+
const readOr = (p, none = null) => { try { return readText(p); } catch { return none; } };
|
|
72
|
+
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readOr(join(dir, configSource)) : null, site: 'https://example.invalid', tree });
|
|
73
|
+
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 ${oneLine(project.foundManifest)}: run this with --into ${oneLine(project.foundManifest.replace(/\/?manifest\.json$/, '')) || '.'})` : ''}.`); return false; }
|
|
70
74
|
ok(project.kind === 'plain' ? 'A plain extension: manifest.json at the top.' : `A ${project.label} project.`);
|
|
71
75
|
|
|
72
76
|
// 2. The Wall's files.
|
|
73
77
|
const at = (f) => join(dir, `${project.codeDir}${f}`);
|
|
74
78
|
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; }
|
|
79
|
+
if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx -y toolaby@latest wire <tool-id>\` here.`); return false; }
|
|
76
80
|
ok(`The Wall's files are in ${project.codeDir || 'the top folder'}.`);
|
|
77
81
|
|
|
78
|
-
// 3. The tool key, and the tool it names.
|
|
79
|
-
const
|
|
82
|
+
// 3. The tool key, and the tool it names — from a file: a link to a device or a pipe is read as nothing.
|
|
83
|
+
const configText = readOr(at('toolaby.config.js'), '');
|
|
84
|
+
const keyText = configText.match(KEY)?.[0];
|
|
80
85
|
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; }
|
|
86
|
+
if (!key) { bad('toolaby.config.js holds no tool key. Run `npx -y toolaby@latest upgrade <tool-id>` here.'); return false; }
|
|
82
87
|
const mode = keyText.split('_')[1];
|
|
88
|
+
// The key is the project's: the Wall it names is asked twice below, with no session, and its tool id goes into a path
|
|
89
|
+
// the developer's session asks of their own Wall. So only a Wall's address (lib/safe.js), and only a tool id. A cloned
|
|
90
|
+
// project's key sent the two requests wherever it named, and a tool id of ".." made the signed-in one a request for
|
|
91
|
+
// another route (25 Sep 2026).
|
|
92
|
+
if (!keySite(key.site) || !TOOL_ID.test(key.slug)) {
|
|
93
|
+
bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a Wall: its address is https (http only on this machine) with nothing after the host, and its id lowercase letters, digits and dashes. Nothing was asked of it. \`npx -y toolaby@latest upgrade <tool-id>\` writes the tool's own key.`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
83
96
|
ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development Wall'} (${key.site}).`);
|
|
84
|
-
const pub = await
|
|
97
|
+
const pub = await fetchBounded(`${key.site}/api/tools/${encodeURIComponent(key.slug)}`).then((r) => ({ status: r.status, json: jsonOf(r.body) })).catch(() => ({ status: 0, json: null }));
|
|
85
98
|
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.`);
|
|
99
|
+
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 -y toolaby@latest tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
|
|
87
100
|
else {
|
|
88
|
-
ok(`The Wall knows it: ${tool.name}.`);
|
|
101
|
+
ok(`The Wall knows it: ${oneLine(tool.name)}.`);
|
|
89
102
|
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.');
|
|
103
|
+
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 -y toolaby@latest upgrade <tool-id>` writes it in.');
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
// 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
107
|
if (project.manifestFile && manifest) {
|
|
96
108
|
const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
|
|
97
109
|
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.`);
|
|
110
|
+
if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.map(oneLine).join('; ')}. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
|
|
99
111
|
else ok('The manifest has the permissions and host permissions the Wall needs.');
|
|
100
112
|
// The identity: without the `key`, each copy's id follows its folder, and once the extension is named the tool key refuses every copy not chosen.
|
|
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 once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
113
|
+
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 once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
|
|
102
114
|
} 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
115
|
|
|
104
116
|
// 5. The client, as fresh as the Wall's.
|
|
105
|
-
const client = await
|
|
117
|
+
const client = await fetchBounded(`${key.site}/client/toolaby.js`).then((r) => (r.ok && r.body ? r.body.toString('utf8') : null)).catch(() => null);
|
|
106
118
|
if (client === null) warn('The Wall\'s current client could not be read to compare.');
|
|
107
|
-
else if (sha(client) !== sha(
|
|
119
|
+
else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not the Wall's current client. \`npx -y toolaby@latest upgrade ${key.slug}\` replaces it (your own files stay).`);
|
|
108
120
|
else ok('toolaby.js is the Wall\'s current client.');
|
|
109
121
|
|
|
110
122
|
// 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
|
|
111
|
-
const sources = sourceFiles(dir).map((f) =>
|
|
123
|
+
const sources = sourceFiles(dir).map((f) => { try { return readText(f); } catch { return ''; } });
|
|
112
124
|
if (project.manifestFile && manifest && sources.some((s) => /\bshowPaywall\s*\(/.test(withoutComments(s)))) {
|
|
113
125
|
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
126
|
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.`);
|
|
127
|
+
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 -y toolaby@latest upgrade ${key.slug}\` writes ${absent.length > 1 ? 'them' : 'it'}; your pages stay in front.`);
|
|
116
128
|
}
|
|
117
129
|
if (tool) {
|
|
118
130
|
const used = new Set();
|
|
@@ -134,7 +146,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
134
146
|
else if (facts?.ok) warn('No copy has checked in yet: load it at chrome://extensions and open it once.');
|
|
135
147
|
// The key carries what the Free plan holds and the kind of plan sold: a change since it was written leaves it behind.
|
|
136
148
|
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.`);
|
|
149
|
+
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 -y toolaby@latest upgrade ${key.slug}\` writes the current one.`);
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
// 8. In a browser: the background answers.
|
|
@@ -168,7 +180,7 @@ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
|
168
180
|
new Promise((r) => setTimeout(() => r('timeout'), 8000)),
|
|
169
181
|
])).catch((e) => `error: ${e.message}`);
|
|
170
182
|
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.`);
|
|
183
|
+
else bad(`The background did not answer (${oneLine(answer ?? 'nothing')}): its module threw while loading, so none of its listeners registered.`);
|
|
172
184
|
await page.close().catch(() => {});
|
|
173
185
|
// The extension's own pages, each opened in a tab: one whose script throws is a popup that opens blank.
|
|
174
186
|
const own = [shipped?.action?.default_popup, shipped?.side_panel?.default_path].filter((p, i, all) => typeof p === 'string' && p && all.indexOf(p) === i);
|
|
@@ -179,12 +191,13 @@ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
|
179
191
|
await tab.goto(`chrome-extension://${id}/${p}`).catch((e) => errors.push(e.message));
|
|
180
192
|
await tab.waitForTimeout(1500);
|
|
181
193
|
await tab.close().catch(() => {});
|
|
182
|
-
if (errors.length) bad(`${p} threw when it opened: ${errors[0]}`);
|
|
183
|
-
else ok(`${p} opens without an error.`);
|
|
194
|
+
if (errors.length) bad(`${oneLine(p)} threw when it opened: ${oneLine(errors[0])}`);
|
|
195
|
+
else ok(`${oneLine(p)} opens without an error.`);
|
|
184
196
|
}
|
|
185
197
|
} finally {
|
|
186
198
|
await ctx.close().catch(() => {});
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
|
|
190
|
-
function readJsonSafe(p) { try { return JSON.parse(
|
|
202
|
+
function readJsonSafe(p) { try { return JSON.parse(readText(p)); } catch { return null; } }
|
|
203
|
+
function jsonOf(body) { try { return body ? JSON.parse(body.toString('utf8')) : null; } catch { return null; } }
|
package/lib/mcp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `toolaby mcp`: the Wall for coding agents, as a Model Context Protocol server on stdio.
|
|
2
2
|
//
|
|
3
|
-
// An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby mcp` —
|
|
3
|
+
// An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby@latest mcp` —
|
|
4
4
|
// and gets tools to read and change the developer's tools on the Wall: what the Free plan holds, features, plans,
|
|
5
5
|
// grants, and the docs. It acts with the session `toolaby login` stored, on Test unless started with --live, through
|
|
6
6
|
// the same routes and the same checks as the dashboard.
|
|
@@ -10,17 +10,34 @@
|
|
|
10
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
11
|
// the tools capability exactly as the specification words them. Nothing but protocol messages goes to stdout.
|
|
12
12
|
|
|
13
|
+
import { fetchBounded } from './safe.js';
|
|
14
|
+
|
|
13
15
|
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
16
|
+
/**
|
|
17
|
+
* A tool id as the Wall makes them. Checked before it goes into a path: `..`, encoded or not, is a path the address
|
|
18
|
+
* resolves away, and `/api/cli/tools/../x` asked for `/api/cli/x` (25 Sep 2026).
|
|
19
|
+
*/
|
|
20
|
+
const TOOL_ID = /^[a-z0-9][a-z0-9-]{1,118}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a tool does, as the specification's annotations say it (2025-03-26 on), for a client deciding what it may run
|
|
24
|
+
* without asking: a read changes nothing, a write changes the developer's own workspace — destructive where it takes
|
|
25
|
+
* away or gives away (what the Free plan holds, the feature list, access to someone), idempotent where a second call
|
|
26
|
+
* changes nothing more. Every tool acts on that workspace on the Wall and nothing beyond it. The writes carried no
|
|
27
|
+
* annotations, and a client took them for whatever its defaults were (25 Sep 2026).
|
|
28
|
+
*/
|
|
29
|
+
const READS = { readOnlyHint: true, openWorldHint: false };
|
|
14
30
|
|
|
15
31
|
/** The tools, with JSON Schema for their arguments, and what each calls on the Wall. */
|
|
16
32
|
function toolsFor(side) {
|
|
17
|
-
const tool = { type: 'string', description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
|
|
33
|
+
const tool = { type: 'string', pattern: TOOL_ID.source, description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
|
|
18
34
|
return [
|
|
19
35
|
{
|
|
20
36
|
name: 'toolaby_whoami',
|
|
21
37
|
title: 'Who is signed in',
|
|
22
38
|
description: `The developer signed in to the ${side} Wall, and their workspaces.`,
|
|
23
39
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
|
+
annotations: READS,
|
|
24
41
|
run: (_a, api) => api('/api/cli/me'),
|
|
25
42
|
},
|
|
26
43
|
{
|
|
@@ -28,6 +45,7 @@ function toolsFor(side) {
|
|
|
28
45
|
title: 'List tools',
|
|
29
46
|
description: `Every tool in the developer's workspaces on the ${side} Wall, with the id the other tools take.`,
|
|
30
47
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
48
|
+
annotations: READS,
|
|
31
49
|
run: (_a, api) => api('/api/cli/tools'),
|
|
32
50
|
},
|
|
33
51
|
{
|
|
@@ -35,12 +53,13 @@ function toolsFor(side) {
|
|
|
35
53
|
title: 'Read a tool',
|
|
36
54
|
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
55
|
inputSchema: { type: 'object', properties: { tool }, required: ['tool'], additionalProperties: false },
|
|
56
|
+
annotations: READS,
|
|
38
57
|
run: (a, api) => api(`/api/cli/tools/${encodeURIComponent(a.tool)}`),
|
|
39
58
|
},
|
|
40
59
|
{
|
|
41
60
|
name: 'toolaby_create_tool',
|
|
42
61
|
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.`,
|
|
62
|
+
description: `A new tool on the ${side} Wall. This changes what the developer's own workspace sells: one more tool. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx -y toolaby@latest create <id>\` for a new one, \`npx -y toolaby@latest wire <id>\` for an existing one.`,
|
|
44
63
|
inputSchema: {
|
|
45
64
|
type: 'object',
|
|
46
65
|
properties: {
|
|
@@ -52,12 +71,13 @@ function toolsFor(side) {
|
|
|
52
71
|
required: ['name'],
|
|
53
72
|
additionalProperties: false,
|
|
54
73
|
},
|
|
74
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
55
75
|
run: (a, api) => api('/api/cli/tools', { method: 'POST', body: a }),
|
|
56
76
|
},
|
|
57
77
|
{
|
|
58
78
|
name: 'toolaby_set_access',
|
|
59
79
|
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.',
|
|
80
|
+
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). This changes access in the developer\'s own workspace, for every buyer of the tool. 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 -y toolaby@latest upgrade <id>` then writes the new one.',
|
|
61
81
|
inputSchema: {
|
|
62
82
|
type: 'object',
|
|
63
83
|
properties: {
|
|
@@ -69,12 +89,13 @@ function toolsFor(side) {
|
|
|
69
89
|
required: ['tool', 'access'],
|
|
70
90
|
additionalProperties: false,
|
|
71
91
|
},
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
72
93
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/access`, { method: 'POST', body: rest }),
|
|
73
94
|
},
|
|
74
95
|
{
|
|
75
96
|
name: 'toolaby_set_features',
|
|
76
97
|
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.',
|
|
98
|
+
description: 'The tool\'s whole feature list (it replaces the current one: send the features it has too), and which plans unlock which. This changes billing in the developer\'s own workspace: what every plan of the tool sells. 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
99
|
inputSchema: {
|
|
79
100
|
type: 'object',
|
|
80
101
|
properties: {
|
|
@@ -102,12 +123,13 @@ function toolsFor(side) {
|
|
|
102
123
|
required: ['tool', 'features'],
|
|
103
124
|
additionalProperties: false,
|
|
104
125
|
},
|
|
126
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
105
127
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/features`, { method: 'POST', body: rest }),
|
|
106
128
|
},
|
|
107
129
|
{
|
|
108
130
|
name: 'toolaby_add_plan',
|
|
109
131
|
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.`,
|
|
132
|
+
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'}). This changes billing in the developer's own workspace. "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 -y toolaby@latest upgrade <id>\` then writes the new one.`,
|
|
111
133
|
inputSchema: {
|
|
112
134
|
type: 'object',
|
|
113
135
|
properties: {
|
|
@@ -124,18 +146,20 @@ function toolsFor(side) {
|
|
|
124
146
|
required: ['tool', 'billing', 'amount', 'currency'],
|
|
125
147
|
additionalProperties: false,
|
|
126
148
|
},
|
|
149
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
127
150
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/plans`, { method: 'POST', body: rest }),
|
|
128
151
|
},
|
|
129
152
|
{
|
|
130
153
|
name: 'toolaby_grant',
|
|
131
154
|
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.',
|
|
155
|
+
description: 'Access to the tool without paying: to test a paid feature on Test, or for a reviewer. This changes access in the developer\'s own workspace: the person gets the paid features, and each call is another grant. 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
156
|
inputSchema: {
|
|
134
157
|
type: 'object',
|
|
135
158
|
properties: { tool, email: { type: 'string' }, days: { type: 'integer', minimum: 1, maximum: 3650, description: 'Optional: how long. Default: until revoked.' } },
|
|
136
159
|
required: ['tool', 'email'],
|
|
137
160
|
additionalProperties: false,
|
|
138
161
|
},
|
|
162
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
139
163
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/grants`, { method: 'POST', body: rest }),
|
|
140
164
|
},
|
|
141
165
|
{
|
|
@@ -143,6 +167,7 @@ function toolsFor(side) {
|
|
|
143
167
|
title: 'Read the docs',
|
|
144
168
|
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
169
|
inputSchema: { type: 'object', properties: { page: { type: 'string' } }, additionalProperties: false },
|
|
170
|
+
annotations: READS,
|
|
146
171
|
run: (a, _api, docs) => docs(a.page),
|
|
147
172
|
},
|
|
148
173
|
];
|
|
@@ -168,15 +193,22 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
168
193
|
const clean = typeof page === 'string' ? page.replace(/^\/+|\.md$|\/+$/g, '').replace(/^docs\//, '') : '';
|
|
169
194
|
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
195
|
const url = clean ? `${wall}/docs/${clean}.md` : `${wall}/llms.txt`;
|
|
171
|
-
|
|
172
|
-
const
|
|
196
|
+
// The Wall's own pages, a few megabytes at most: a redirect elsewhere handed an agent any host's page as the docs (25 Sep 2026).
|
|
197
|
+
const res = await fetchBounded(url, { limit: 4 * 1024 * 1024 }).catch(() => ({ ok: false, status: 0, body: null }));
|
|
198
|
+
if (res.ok && !res.body) return { ok: false, status: 413, json: { message: 'The docs page is larger than any the Wall serves.' } };
|
|
199
|
+
const text = res.body ? res.body.toString('utf8') : '';
|
|
173
200
|
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
201
|
};
|
|
175
202
|
const call = async (name, args) => {
|
|
176
203
|
const t = tools.find((x) => x.name === name);
|
|
177
204
|
if (!t) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
|
|
205
|
+
// Arguments are an object, or none: anything else threw before the answer, and the request went unanswered.
|
|
206
|
+
if (args !== undefined && (args === null || typeof args !== 'object' || Array.isArray(args))) return { error: { code: -32602, message: 'Arguments are an object.' } };
|
|
207
|
+
if (args && 'tool' in args && (typeof args.tool !== 'string' || !TOOL_ID.test(args.tool))) {
|
|
208
|
+
return { result: { content: [{ type: 'text', text: 'A tool id is lowercase letters, digits and dashes, as toolaby_list_tools gives it.' }], isError: true } };
|
|
209
|
+
}
|
|
178
210
|
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 } };
|
|
211
|
+
return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx -y toolaby@latest login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
|
|
180
212
|
}
|
|
181
213
|
try {
|
|
182
214
|
return { result: resultOf(await t.run(args ?? {}, api, docs)) };
|
|
@@ -197,7 +229,7 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
197
229
|
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
198
230
|
capabilities: { tools: { listChanged: false } },
|
|
199
231
|
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.`,
|
|
232
|
+
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 -y toolaby@latest create|wire|upgrade <id>, check with npx -y toolaby@latest check. toolaby_docs reads the docs.`,
|
|
201
233
|
} });
|
|
202
234
|
}
|
|
203
235
|
case 'notifications/initialized':
|
|
@@ -216,7 +248,7 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
216
248
|
}
|
|
217
249
|
};
|
|
218
250
|
|
|
219
|
-
log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx toolaby login`'}`);
|
|
251
|
+
log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx -y toolaby@latest login`'}`);
|
|
220
252
|
let buffer = '';
|
|
221
253
|
process.stdin.setEncoding('utf8');
|
|
222
254
|
process.stdin.on('data', (chunk) => {
|
package/lib/project.js
CHANGED
|
@@ -120,11 +120,13 @@ export function detectProject(o) {
|
|
|
120
120
|
if (crxjs && !has('manifest.json')) {
|
|
121
121
|
const configFile = first(has, ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'manifest.js', 'src/manifest.ts', 'src/manifest.js']);
|
|
122
122
|
const source = o.manifestSource ?? '';
|
|
123
|
-
|
|
123
|
+
// A name as one line of a path: read across a line break or a backslash, a project's config drew lines of its own
|
|
124
|
+
// into the snippets the command prints and the dashboard shows (25 Sep 2026).
|
|
125
|
+
const declared = source.match(/default_popup:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? null;
|
|
124
126
|
const appPopup = declared && declared !== 'toolaby-popup.html' ? declared : null;
|
|
125
|
-
const declaredPanel = source.match(/default_path:\s*['"]([^'"]+)['"]/)?.[1] ?? null;
|
|
127
|
+
const declaredPanel = source.match(/default_path:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? null;
|
|
126
128
|
const appSidePanel = declaredPanel && declaredPanel !== 'toolaby-sidepanel.html' ? declaredPanel : null;
|
|
127
|
-
const worker = source.match(/service_worker:\s*['"]([^'"]+)['"]/)?.[1] ?? first(has, ['src/background.ts', 'src/background.js', 'background.ts', 'background.js']);
|
|
129
|
+
const worker = source.match(/service_worker:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? first(has, ['src/background.ts', 'src/background.js', 'background.ts', 'background.js']);
|
|
128
130
|
const workerFile = worker ?? 'src/background.ts';
|
|
129
131
|
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
130
132
|
const code = [
|