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/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, readFileSync, readdirSync, statSync } from 'node:fs';
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 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; }
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 keyText = readFileSync(at('toolaby.config.js'), 'utf8').match(KEY)?.[0];
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 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 }));
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 fetch(`${key.site}/client/toolaby.js`).then((r) => (r.ok ? r.text() : null)).catch(() => null);
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(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).`);
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) => readFileSync(f, 'utf8'));
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(readFileSync(p, 'utf8')); } catch { return null; } }
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
- const res = await fetch(url).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
172
- const text = await res.text();
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
- const declared = source.match(/default_popup:\s*['"]([^'"]+)['"]/)?.[1] ?? null;
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 = [