@hanna84/mcp-writing 1.9.1 → 1.9.2
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/CHANGELOG.md +10 -0
- package/README.md +7 -31
- package/package.json +2 -1
- package/scripts/generate-tool-docs.mjs +458 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v1.9.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.9.1...v1.9.2)
|
|
9
|
+
|
|
10
|
+
- docs: generate tool reference and enforce it in CI [`#55`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/55)
|
|
12
|
+
|
|
7
13
|
#### [v1.9.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
14
|
/compare/v1.9.0...v1.9.1)
|
|
9
15
|
|
|
16
|
+
> 20 April 2026
|
|
17
|
+
|
|
10
18
|
- docs: reorganize PRD structure and move OpenClaw integration to in-progress [`#54`](https://github.com/hannasdev/mcp-writing.git
|
|
11
19
|
/pull/54)
|
|
20
|
+
- Release 1.9.1 [`24540fc`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/24540fc3e6cbcd3d68621479e47fa00d6db562d6)
|
|
12
22
|
|
|
13
23
|
#### [v1.9.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
24
|
/compare/v1.8.1...v1.9.0)
|
package/README.md
CHANGED
|
@@ -300,37 +300,13 @@ Outcome: you get AI speed with explicit approval and recoverable history for eve
|
|
|
300
300
|
|
|
301
301
|
## Reference: Available tools
|
|
302
302
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
| `get_runtime_config` | Show active paths/capabilities plus runtime warnings and setup recommendations |
|
|
311
|
-
| `get_arc` | Ordered scene metadata for all scenes involving a character |
|
|
312
|
-
| `list_characters` | All characters, optionally filtered by project or universe |
|
|
313
|
-
| `get_character_sheet` | Full character metadata, traits, notes, and support notes |
|
|
314
|
-
| `create_character_sheet` | Create a canonical character sheet folder and sidecar |
|
|
315
|
-
| `list_places` | All places |
|
|
316
|
-
| `get_place_sheet` | Full place metadata, tags, associated characters, notes, and support notes |
|
|
317
|
-
| `create_place_sheet` | Create a canonical place sheet folder and sidecar |
|
|
318
|
-
| `search_metadata` | Full-text search across scene titles, loglines, and metadata keywords (tags/characters/places/versions) |
|
|
319
|
-
| `list_threads` | All subplot threads for a project |
|
|
320
|
-
| `get_thread_arc` | Scenes belonging to a thread, with per-thread beat |
|
|
321
|
-
| `upsert_thread_link` | Create/update a thread and link it to a scene |
|
|
322
|
-
| `enrich_scene` | Re-derive lightweight metadata from current prose and clear `metadata_stale` |
|
|
323
|
-
| `update_scene_metadata` | Write metadata fields back to a scene sidecar |
|
|
324
|
-
| `update_character_sheet` | Write fields back to a character sidecar |
|
|
325
|
-
| `update_place_sheet` | Write fields back to a place sidecar |
|
|
326
|
-
| `flag_scene` | Mark a scene with a flag for AI follow-up |
|
|
327
|
-
| `propose_edit` | Stage a scene revision for review without writing it |
|
|
328
|
-
| `commit_edit` | Apply a staged prose edit and create a git-backed snapshot |
|
|
329
|
-
| `discard_edit` | Discard a pending staged prose edit |
|
|
330
|
-
| `snapshot_scene` | Create a manual git snapshot for a scene |
|
|
331
|
-
| `list_snapshots` | List snapshot history for a scene |
|
|
332
|
-
|
|
333
|
-
Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `search_metadata`) accept `page` and `page_size` arguments and return `total_count` / `total_pages` in the response envelope.
|
|
303
|
+
The full tool reference — with every parameter, type, and description — is auto-generated from source and lives at **[docs/tools.md](docs/tools.md)**.
|
|
304
|
+
|
|
305
|
+
To regenerate after editing tool definitions in `index.js`:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
npm run docs
|
|
309
|
+
```
|
|
334
310
|
|
|
335
311
|
---
|
|
336
312
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
|
|
25
25
|
"release": "release-it",
|
|
26
26
|
"lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
|
|
27
|
+
"docs": "node scripts/generate-tool-docs.mjs",
|
|
27
28
|
"lint:metadata": "node scripts/lint-metadata.mjs",
|
|
28
29
|
"test:unit": "node --experimental-sqlite --test test/unit.test.mjs",
|
|
29
30
|
"test:integration": "node --experimental-sqlite --test test/integration.test.mjs",
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generates docs/tools.md from tool definitions in index.js.
|
|
4
|
+
*
|
|
5
|
+
* Run: node scripts/generate-tool-docs.mjs
|
|
6
|
+
* or: npm run docs
|
|
7
|
+
*
|
|
8
|
+
* The output is the single source of truth for the tool reference.
|
|
9
|
+
* Re-run after editing tool names, descriptions, or parameters in index.js.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const SRC = path.join(ROOT, 'index.js');
|
|
18
|
+
const OUT = path.join(ROOT, 'docs', 'tools.md');
|
|
19
|
+
|
|
20
|
+
const source = readFileSync(SRC, 'utf8');
|
|
21
|
+
|
|
22
|
+
function decodeEscape(src, i) {
|
|
23
|
+
const esc = src[i];
|
|
24
|
+
|
|
25
|
+
if (esc === undefined) {
|
|
26
|
+
return { value: '', end: i };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
switch (esc) {
|
|
30
|
+
case 'n': return { value: '\n', end: i + 1 };
|
|
31
|
+
case 'r': return { value: '\r', end: i + 1 };
|
|
32
|
+
case 't': return { value: '\t', end: i + 1 };
|
|
33
|
+
case 'b': return { value: '\b', end: i + 1 };
|
|
34
|
+
case 'f': return { value: '\f', end: i + 1 };
|
|
35
|
+
case 'v': return { value: '\v', end: i + 1 };
|
|
36
|
+
case '0': return { value: '\0', end: i + 1 };
|
|
37
|
+
case '\\': return { value: '\\', end: i + 1 };
|
|
38
|
+
case '"': return { value: '"', end: i + 1 };
|
|
39
|
+
case "'": return { value: "'", end: i + 1 };
|
|
40
|
+
case '`': return { value: '`', end: i + 1 };
|
|
41
|
+
case 'x': {
|
|
42
|
+
const hex = src.slice(i + 1, i + 3);
|
|
43
|
+
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
44
|
+
return { value: String.fromCodePoint(Number.parseInt(hex, 16)), end: i + 3 };
|
|
45
|
+
}
|
|
46
|
+
return { value: 'x', end: i + 1 };
|
|
47
|
+
}
|
|
48
|
+
case 'u': {
|
|
49
|
+
if (src[i + 1] === '{') {
|
|
50
|
+
const close = src.indexOf('}', i + 2);
|
|
51
|
+
const codePoint = close === -1 ? '' : src.slice(i + 2, close);
|
|
52
|
+
if (/^[0-9a-fA-F]+$/.test(codePoint)) {
|
|
53
|
+
return { value: String.fromCodePoint(Number.parseInt(codePoint, 16)), end: close + 1 };
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
const hex = src.slice(i + 1, i + 5);
|
|
57
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
58
|
+
return { value: String.fromCodePoint(Number.parseInt(hex, 16)), end: i + 5 };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { value: 'u', end: i + 1 };
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return { value: esc, end: i + 1 };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readQuotedLiteral(src, i, quote) {
|
|
69
|
+
let str = '';
|
|
70
|
+
|
|
71
|
+
while (i < src.length) {
|
|
72
|
+
if (src[i] === '\\') {
|
|
73
|
+
const decoded = decodeEscape(src, i + 1);
|
|
74
|
+
str += decoded.value;
|
|
75
|
+
i = decoded.end;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (src[i] === quote) {
|
|
79
|
+
return { text: str, end: i + 1 };
|
|
80
|
+
}
|
|
81
|
+
str += src[i++];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { text: str, end: i };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readTemplateExpression(src, i) {
|
|
88
|
+
const start = i;
|
|
89
|
+
let depth = 1;
|
|
90
|
+
|
|
91
|
+
while (i < src.length && depth > 0) {
|
|
92
|
+
const ch = src[i];
|
|
93
|
+
|
|
94
|
+
if (ch === '"' || ch === "'") {
|
|
95
|
+
i = skipString(src, i + 1, ch);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === '`') {
|
|
99
|
+
i = skipString(src, i + 1, ch);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '{') depth++;
|
|
103
|
+
if (ch === '}') depth--;
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { text: src.slice(start, i - 1).trim(), end: i };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractConstantValues(src) {
|
|
111
|
+
const values = new Map();
|
|
112
|
+
|
|
113
|
+
for (const match of src.matchAll(/const\s+(\w+)\s*=\s*parseInt\([^\n]*\?\?\s*"([^"]+)"[^\n]*\);/g)) {
|
|
114
|
+
values.set(match[1], Number.parseInt(match[2], 10));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const match of src.matchAll(/const\s+(\w+)\s*=\s*process\.env\.\w+\s*\?\?\s*(["'`])([\s\S]*?)\2;/g)) {
|
|
118
|
+
if (!values.has(match[1])) {
|
|
119
|
+
values.set(match[1], match[3]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const match of src.matchAll(/const\s+(\w+)\s*=\s*(\d+|true|false);/g)) {
|
|
124
|
+
if (!values.has(match[1])) {
|
|
125
|
+
const raw = match[2];
|
|
126
|
+
values.set(match[1], raw === 'true' ? true : raw === 'false' ? false : Number.parseInt(raw, 10));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return values;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const constantValues = extractConstantValues(source);
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Step 1: Extract raw text of each s.tool(...) call
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Walk `src` from `start`, skipping over a quoted string (the opening quote
|
|
141
|
+
* character at `src[start]` is already consumed — cursor is just inside it).
|
|
142
|
+
* Returns the index just past the closing quote.
|
|
143
|
+
*/
|
|
144
|
+
function skipString(src, i, quote) {
|
|
145
|
+
while (i < src.length) {
|
|
146
|
+
if (src[i] === '\\') { i += 2; continue; }
|
|
147
|
+
if (src[i] === quote) return i + 1;
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
return i;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns an array of raw block strings, one per s.tool() registration.
|
|
155
|
+
* Each block covers from 's' of 's.tool(' to the matching ')'.
|
|
156
|
+
*/
|
|
157
|
+
function extractToolBlocks(src) {
|
|
158
|
+
const blocks = [];
|
|
159
|
+
const re = /\bs\.tool\(/g;
|
|
160
|
+
let m;
|
|
161
|
+
|
|
162
|
+
while ((m = re.exec(src)) !== null) {
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let j = m.index + m[0].length - 1; // position of the opening '('
|
|
165
|
+
|
|
166
|
+
while (j < src.length) {
|
|
167
|
+
const ch = src[j];
|
|
168
|
+
if (ch === '(') depth++;
|
|
169
|
+
else if (ch === ')') { depth--; if (depth === 0) break; }
|
|
170
|
+
else if (ch === '"' || ch === "'") { j = skipString(src, j + 1, ch) - 1; }
|
|
171
|
+
else if (ch === '`') { j = skipString(src, j + 1, '`') - 1; }
|
|
172
|
+
j++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
blocks.push(src.substring(m.index, j + 1));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return blocks;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Step 2: Parse a tool block into { name, description, params }
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extracts the first N string literal values from `src` (in order).
|
|
187
|
+
*/
|
|
188
|
+
function extractStringArgs(src, count) {
|
|
189
|
+
const results = [];
|
|
190
|
+
let i = 0;
|
|
191
|
+
while (i < src.length && results.length < count) {
|
|
192
|
+
const ch = src[i];
|
|
193
|
+
if (ch === '"' || ch === "'") {
|
|
194
|
+
const { text, end } = readQuotedLiteral(src, i + 1, ch);
|
|
195
|
+
results.push(text);
|
|
196
|
+
i = end;
|
|
197
|
+
} else if (ch === '`') {
|
|
198
|
+
const { text, end } = readTemplateLiteral(src, i + 1);
|
|
199
|
+
results.push(text);
|
|
200
|
+
i = end;
|
|
201
|
+
} else {
|
|
202
|
+
i++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return results;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Finds the balanced {…} block that starts at or after `fromIndex` in `src`,
|
|
210
|
+
* skipping strings and other brackets. Returns { text, end } or null.
|
|
211
|
+
*/
|
|
212
|
+
function extractBalancedBraces(src, fromIndex) {
|
|
213
|
+
let i = fromIndex;
|
|
214
|
+
while (i < src.length && src[i] !== '{') i++;
|
|
215
|
+
if (i >= src.length) return null;
|
|
216
|
+
|
|
217
|
+
const start = i;
|
|
218
|
+
let depth = 0;
|
|
219
|
+
while (i < src.length) {
|
|
220
|
+
const ch = src[i];
|
|
221
|
+
if (ch === '{') depth++;
|
|
222
|
+
else if (ch === '}') { depth--; if (depth === 0) { i++; break; } }
|
|
223
|
+
else if (ch === '"' || ch === "'") { i = skipString(src, i + 1, ch) - 1; }
|
|
224
|
+
else if (ch === '`') { i = skipString(src, i + 1, '`') - 1; }
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { text: src.substring(start, i), end: i };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readTemplateLiteral(src, i) {
|
|
232
|
+
let str = '';
|
|
233
|
+
while (i < src.length) {
|
|
234
|
+
if (src[i] === "\\") {
|
|
235
|
+
const decoded = decodeEscape(src, i + 1);
|
|
236
|
+
str += decoded.value;
|
|
237
|
+
i = decoded.end;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (src[i] === '$' && src[i + 1] === '{') {
|
|
241
|
+
const { text, end } = readTemplateExpression(src, i + 2);
|
|
242
|
+
const value = constantValues.get(text);
|
|
243
|
+
str += value === undefined ? `\${${text}}` : String(value);
|
|
244
|
+
i = end;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (src[i] === '`') return { text: str, end: i + 1 };
|
|
248
|
+
str += src[i++];
|
|
249
|
+
}
|
|
250
|
+
return { text: str, end: i };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Finds the position in `block` right after the 2nd string argument + comma.
|
|
255
|
+
* Used to locate where the schema object begins.
|
|
256
|
+
*/
|
|
257
|
+
function posAfterTwoStringArgs(block) {
|
|
258
|
+
// Skip "s.tool("
|
|
259
|
+
let i = block.indexOf('s.tool(') + 7;
|
|
260
|
+
let count = 0;
|
|
261
|
+
while (i < block.length && count < 2) {
|
|
262
|
+
const ch = block[i];
|
|
263
|
+
if (ch === '"' || ch === "'") {
|
|
264
|
+
i = skipString(block, i + 1, ch);
|
|
265
|
+
count++;
|
|
266
|
+
} else if (ch === '`') {
|
|
267
|
+
const { end } = readTemplateLiteral(block, i + 1);
|
|
268
|
+
i = end;
|
|
269
|
+
count++;
|
|
270
|
+
} else {
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return i; // points to content after the description string
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Splits a schema text {…} at top-level commas (not inside nested brackets or
|
|
279
|
+
* strings), yielding one string per top-level parameter declaration.
|
|
280
|
+
* Multi-line z.object({…}) params are returned as a single string.
|
|
281
|
+
*/
|
|
282
|
+
function splitTopLevelParams(schemaText) {
|
|
283
|
+
const sections = [];
|
|
284
|
+
// Skip the outer { }
|
|
285
|
+
const inner = schemaText.slice(1, schemaText.length - 1);
|
|
286
|
+
|
|
287
|
+
let depth = 0; // {} depth
|
|
288
|
+
let pdepth = 0; // () depth
|
|
289
|
+
let adepth = 0; // [] depth
|
|
290
|
+
let sectionStart = 0;
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < inner.length; i++) {
|
|
293
|
+
const ch = inner[i];
|
|
294
|
+
|
|
295
|
+
if (ch === '"' || ch === "'") { i = skipString(inner, i + 1, ch) - 1; }
|
|
296
|
+
else if (ch === '`') { i = skipString(inner, i + 1, '`') - 1; }
|
|
297
|
+
else if (ch === '{') depth++;
|
|
298
|
+
else if (ch === '}') depth--;
|
|
299
|
+
else if (ch === '(') pdepth++;
|
|
300
|
+
else if (ch === ')') pdepth--;
|
|
301
|
+
else if (ch === '[') adepth++;
|
|
302
|
+
else if (ch === ']') adepth--;
|
|
303
|
+
else if (ch === ',' && depth === 0 && pdepth === 0 && adepth === 0) {
|
|
304
|
+
const text = inner.substring(sectionStart, i).trim();
|
|
305
|
+
if (text) sections.push(text);
|
|
306
|
+
sectionStart = i + 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Push whatever remains after the last comma
|
|
311
|
+
const tail = inner.substring(sectionStart).trim();
|
|
312
|
+
if (tail) sections.push(tail);
|
|
313
|
+
|
|
314
|
+
return sections.filter(s => /^\w/.test(s)); // skip blank/whitespace chunks
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Derives a display type string from a Zod chain.
|
|
319
|
+
* Works on the raw text of a single parameter declaration.
|
|
320
|
+
*/
|
|
321
|
+
function zodTypeString(text) {
|
|
322
|
+
// Extract the base type: z.TYPE(
|
|
323
|
+
const base = (text.match(/z\.(\w+)\(/) ?? [])[1] ?? 'unknown';
|
|
324
|
+
|
|
325
|
+
if (base === 'number') {
|
|
326
|
+
return /\.int\(\)/.test(text) ? 'integer' : 'number';
|
|
327
|
+
}
|
|
328
|
+
if (base === 'array') {
|
|
329
|
+
const inner = (text.match(/z\.array\(\s*z\.(\w+)\(\)/) ?? [])[1];
|
|
330
|
+
return inner ? `${inner}[]` : 'array';
|
|
331
|
+
}
|
|
332
|
+
if (base === 'object') return 'object';
|
|
333
|
+
if (base === 'boolean') return 'boolean';
|
|
334
|
+
if (base === 'string') return 'string';
|
|
335
|
+
if (base === 'enum') {
|
|
336
|
+
const vals = (text.match(/z\.enum\(\[([^\]]+)\]/) ?? [])[1];
|
|
337
|
+
return vals ? `enum(${vals.replace(/\s+/g, '')})` : 'enum';
|
|
338
|
+
}
|
|
339
|
+
return base;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Parses the parameters from a schema text block.
|
|
344
|
+
* Returns [{name, type, optional, description}].
|
|
345
|
+
*/
|
|
346
|
+
function parseParams(schemaText) {
|
|
347
|
+
if (!schemaText) return [];
|
|
348
|
+
const trimmed = schemaText.trim();
|
|
349
|
+
if (trimmed === '{}') return [];
|
|
350
|
+
|
|
351
|
+
const sections = splitTopLevelParams(schemaText);
|
|
352
|
+
const params = [];
|
|
353
|
+
|
|
354
|
+
for (const section of sections) {
|
|
355
|
+
const nameMatch = section.match(/^([\w_]+)\s*:/);
|
|
356
|
+
if (!nameMatch) continue;
|
|
357
|
+
|
|
358
|
+
const name = nameMatch[1];
|
|
359
|
+
const type = zodTypeString(section);
|
|
360
|
+
const optional = /\.optional\(\)/.test(section);
|
|
361
|
+
|
|
362
|
+
// .describe("...") may be on any line of the section.
|
|
363
|
+
// Use the *last* .describe() in the section so that z.object({...}).describe("outer")
|
|
364
|
+
// wins over any .describe() calls on inner fields.
|
|
365
|
+
// Use readQuotedLiteral so JS escape sequences (\u2019 etc.) are decoded, not left raw.
|
|
366
|
+
let description = '';
|
|
367
|
+
const descRe = /\.describe\("/g;
|
|
368
|
+
let descMatch;
|
|
369
|
+
while ((descMatch = descRe.exec(section)) !== null) {
|
|
370
|
+
const { text } = readQuotedLiteral(section, descMatch.index + descMatch[0].length, '"');
|
|
371
|
+
description = text; // keep iterating to get the last one
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
params.push({ name, type, optional, description });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return params;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse a single tool block into { name, description, params }.
|
|
382
|
+
*/
|
|
383
|
+
function parseTool(block) {
|
|
384
|
+
const [name, description] = extractStringArgs(block, 2);
|
|
385
|
+
if (!name) return null;
|
|
386
|
+
|
|
387
|
+
const schemaStart = posAfterTwoStringArgs(block);
|
|
388
|
+
const schema = extractBalancedBraces(block, schemaStart);
|
|
389
|
+
const params = schema ? parseParams(schema.text) : [];
|
|
390
|
+
|
|
391
|
+
return { name, description, params };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Step 3: Generate markdown
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/** Converts a tool name to the GitHub heading slug used by this document. */
|
|
399
|
+
function anchor(name) {
|
|
400
|
+
return name.toLowerCase();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function generateMarkdown(tools) {
|
|
404
|
+
const lines = [
|
|
405
|
+
'# Tool Reference',
|
|
406
|
+
'',
|
|
407
|
+
'> Auto-generated from `index.js`.',
|
|
408
|
+
'> Do not edit manually — run `npm run docs` to regenerate.',
|
|
409
|
+
'',
|
|
410
|
+
'## Tools',
|
|
411
|
+
'',
|
|
412
|
+
...tools.map(t => `- [\`${t.name}\`](#${anchor(t.name)})`),
|
|
413
|
+
'',
|
|
414
|
+
'---',
|
|
415
|
+
'',
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
for (const tool of tools) {
|
|
419
|
+
lines.push(`## ${tool.name}`);
|
|
420
|
+
lines.push('');
|
|
421
|
+
lines.push(tool.description);
|
|
422
|
+
lines.push('');
|
|
423
|
+
|
|
424
|
+
if (tool.params.length === 0) {
|
|
425
|
+
lines.push('_No parameters._');
|
|
426
|
+
} else {
|
|
427
|
+
lines.push('| Parameter | Type | Required | Description |');
|
|
428
|
+
lines.push('|-----------|------|:--------:|-------------|');
|
|
429
|
+
for (const p of tool.params) {
|
|
430
|
+
const req = p.optional ? '' : '✓';
|
|
431
|
+
const desc = p.description.replace(/\|/g, '\\|'); // escape pipes
|
|
432
|
+
lines.push(`| \`${p.name}\` | \`${p.type}\` | ${req} | ${desc} |`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
lines.push('');
|
|
437
|
+
lines.push('---');
|
|
438
|
+
lines.push('');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return lines.join('\n');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Main
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
const blocks = extractToolBlocks(source);
|
|
449
|
+
const tools = blocks.map(parseTool).filter(Boolean);
|
|
450
|
+
|
|
451
|
+
mkdirSync(path.join(ROOT, 'docs'), { recursive: true });
|
|
452
|
+
const md = generateMarkdown(tools);
|
|
453
|
+
writeFileSync(OUT, md, 'utf8');
|
|
454
|
+
|
|
455
|
+
const paramCounts = tools.map(t => ` ${t.name}: ${t.params.length} param(s)`);
|
|
456
|
+
console.log(`Generated ${OUT}`);
|
|
457
|
+
console.log(` ${tools.length} tools documented`);
|
|
458
|
+
console.log(paramCounts.join('\n'));
|