wp-devdocs-mcp 1.1.1

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.
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import {
5
+ addSource,
6
+ listSources,
7
+ getSource,
8
+ removeSource,
9
+ searchHooks,
10
+ searchBlockApis,
11
+ searchDocs,
12
+ validateHook,
13
+ getStats,
14
+ rebuildFtsIndex,
15
+ isSourceIndexed,
16
+ getStaleSources,
17
+ closeDb,
18
+ } from '../src/db/sqlite.js';
19
+ import { indexSources } from '../src/indexer/index-manager.js';
20
+ import { getPreset, listPresets } from '../src/presets.js';
21
+
22
+ const program = new Command();
23
+
24
+ program
25
+ .name('wp-hooks')
26
+ .description('WordPress hook indexer, doc searcher, and MCP server CLI')
27
+ .version('2.0.0');
28
+
29
+ function printIndexStats(stats) {
30
+ console.log(` Files processed: ${stats.files_processed}`);
31
+ console.log(` Files skipped: ${stats.files_skipped}`);
32
+ console.log(` Hooks inserted: ${stats.hooks_inserted}`);
33
+ console.log(` Hooks updated: ${stats.hooks_updated}`);
34
+ console.log(` Hooks unchanged: ${stats.hooks_skipped}`);
35
+ console.log(` Hooks removed: ${stats.hooks_removed}`);
36
+ console.log(` Blocks indexed: ${stats.blocks_indexed}`);
37
+ console.log(` APIs indexed: ${stats.apis_indexed}`);
38
+ console.log(` Docs inserted: ${stats.docs_inserted}`);
39
+ console.log(` Docs updated: ${stats.docs_updated}`);
40
+ console.log(` Docs unchanged: ${stats.docs_skipped}`);
41
+ console.log(` Docs removed: ${stats.docs_removed}`);
42
+
43
+ if (stats.errors.length > 0) {
44
+ console.log(`\n Errors (${stats.errors.length}):`);
45
+ for (const err of stats.errors) {
46
+ console.log(` - ${err}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ // --- source:add ---
52
+ program
53
+ .command('source:add')
54
+ .description('Add a new source to index')
55
+ .requiredOption('--name <name>', 'Unique source name')
56
+ .requiredOption('--type <type>', 'Source type: github-public, github-private, local-folder')
57
+ .option('--repo <url>', 'Repository URL (for github types)')
58
+ .option('--subfolder <path>', 'Subfolder within repo to index')
59
+ .option('--path <path>', 'Local folder path (for local-folder type)')
60
+ .option('--token-env <var>', 'Environment variable name containing GitHub token')
61
+ .option('--branch <branch>', 'Git branch (default: main)', 'main')
62
+ .option('--content-type <type>', 'Content type: source or docs (default: source)', 'source')
63
+ .option('--no-index', 'Skip automatic indexing after adding')
64
+ .action(async (opts) => {
65
+ try {
66
+ const existing = getSource(opts.name);
67
+ if (existing) {
68
+ console.error(`Source "${opts.name}" already exists. Remove it first.`);
69
+ process.exit(1);
70
+ }
71
+
72
+ addSource({
73
+ name: opts.name,
74
+ type: opts.type,
75
+ repo_url: opts.repo || null,
76
+ subfolder: opts.subfolder || null,
77
+ local_path: opts.path || null,
78
+ token_env_var: opts.tokenEnv || null,
79
+ branch: opts.branch,
80
+ content_type: opts.contentType,
81
+ });
82
+
83
+ console.log(`Source "${opts.name}" added successfully (content_type: ${opts.contentType}).`);
84
+
85
+ if (opts.index) {
86
+ console.log(`\nIndexing "${opts.name}"...`);
87
+ const stats = await indexSources({ sourceName: opts.name });
88
+ console.log('\nIndexing complete:');
89
+ printIndexStats(stats);
90
+ }
91
+ } catch (err) {
92
+ console.error(`Error: ${err.message}`);
93
+ process.exit(1);
94
+ } finally {
95
+ closeDb();
96
+ }
97
+ });
98
+
99
+ // --- source:list ---
100
+
101
+ function formatRelativeTime(isoString) {
102
+ if (!isoString) return 'never';
103
+ const diff = Date.now() - new Date(isoString + 'Z').getTime();
104
+ const seconds = Math.floor(diff / 1000);
105
+ if (seconds < 60) return `${seconds}s ago`;
106
+ const minutes = Math.floor(seconds / 60);
107
+ if (minutes < 60) return `${minutes}m ago`;
108
+ const hours = Math.floor(minutes / 60);
109
+ if (hours < 24) return `${hours}h ago`;
110
+ const days = Math.floor(hours / 24);
111
+ return `${days}d ago`;
112
+ }
113
+
114
+ program
115
+ .command('source:list')
116
+ .description('List all configured sources')
117
+ .action(() => {
118
+ try {
119
+ const sources = listSources();
120
+ if (sources.length === 0) {
121
+ console.log('No sources configured. Use "wp-hooks source:add" to add one.');
122
+ return;
123
+ }
124
+
125
+ console.log(`\n${'Name'.padEnd(25)} ${'Type'.padEnd(18)} ${'Content'.padEnd(10)} ${'Branch'.padEnd(10)} ${'Indexed'.padEnd(10)} ${'Last indexed'.padEnd(14)} Details`);
126
+ console.log('-'.repeat(124));
127
+
128
+ for (const s of sources) {
129
+ const details = s.repo_url || s.local_path || '';
130
+ const subfolder = s.subfolder ? ` [${s.subfolder}]` : '';
131
+ const indexed = isSourceIndexed(s.id) ? 'yes' : 'no';
132
+ const contentType = s.content_type || 'source';
133
+ const lastIndexed = formatRelativeTime(s.last_indexed_at);
134
+ console.log(
135
+ `${s.name.padEnd(25)} ${s.type.padEnd(18)} ${contentType.padEnd(10)} ${(s.branch || 'main').padEnd(10)} ${indexed.padEnd(10)} ${lastIndexed.padEnd(14)} ${details}${subfolder}`
136
+ );
137
+ }
138
+ console.log('');
139
+ } catch (err) {
140
+ console.error(`Error: ${err.message}`);
141
+ process.exit(1);
142
+ } finally {
143
+ closeDb();
144
+ }
145
+ });
146
+
147
+ // --- source:remove ---
148
+ program
149
+ .command('source:remove <name>')
150
+ .description('Remove a source and all its indexed data')
151
+ .action((name) => {
152
+ try {
153
+ const removed = removeSource(name);
154
+ if (!removed) {
155
+ console.error(`Source "${name}" not found.`);
156
+ process.exit(1);
157
+ }
158
+ console.log(`Source "${name}" removed along with all indexed data.`);
159
+ } catch (err) {
160
+ console.error(`Error: ${err.message}`);
161
+ process.exit(1);
162
+ } finally {
163
+ closeDb();
164
+ }
165
+ });
166
+
167
+ // --- quick-add ---
168
+ program
169
+ .command('quick-add <preset>')
170
+ .description('Add and index a pre-configured source. Available presets: ' + listPresets().map(p => p.name).join(', '))
171
+ .option('--no-index', 'Skip automatic indexing after adding')
172
+ .action(async (presetName, opts) => {
173
+ try {
174
+ const preset = getPreset(presetName);
175
+ if (!preset) {
176
+ console.error(`Unknown preset: "${presetName}". Available: ${listPresets().map(p => p.name).join(', ')}`);
177
+ process.exit(1);
178
+ }
179
+
180
+ const existing = getSource(preset.name);
181
+ if (existing) {
182
+ console.log(`Source "${preset.name}" already exists. Skipping add, running index...`);
183
+ } else {
184
+ addSource(preset);
185
+ console.log(`Source "${preset.name}" added (${preset.content_type}).`);
186
+ }
187
+
188
+ if (opts.index) {
189
+ console.log(`\nIndexing "${preset.name}"...`);
190
+ const stats = await indexSources({ sourceName: preset.name });
191
+ console.log('\nIndexing complete:');
192
+ printIndexStats(stats);
193
+ }
194
+ } catch (err) {
195
+ console.error(`Error: ${err.message}`);
196
+ process.exit(1);
197
+ } finally {
198
+ closeDb();
199
+ }
200
+ });
201
+
202
+ // --- quick-add-all ---
203
+ program
204
+ .command('quick-add-all')
205
+ .description('Add and index all pre-configured sources')
206
+ .option('--no-index', 'Skip automatic indexing after adding')
207
+ .action(async (opts) => {
208
+ try {
209
+ const presets = listPresets();
210
+ for (const preset of presets) {
211
+ const existing = getSource(preset.name);
212
+ if (existing) {
213
+ console.log(`Source "${preset.name}" already exists — skipping.`);
214
+ continue;
215
+ }
216
+ addSource(preset);
217
+ console.log(`Added: ${preset.name} (${preset.content_type})`);
218
+ }
219
+
220
+ if (opts.index) {
221
+ console.log('\nIndexing all sources...');
222
+ const stats = await indexSources();
223
+ console.log('\nIndexing complete:');
224
+ console.log(` Sources processed: ${stats.sources_processed}`);
225
+ printIndexStats(stats);
226
+ }
227
+ } catch (err) {
228
+ console.error(`Error: ${err.message}`);
229
+ process.exit(1);
230
+ } finally {
231
+ closeDb();
232
+ }
233
+ });
234
+
235
+ // --- index ---
236
+ program
237
+ .command('index')
238
+ .description('Index all enabled sources (or a specific one)')
239
+ .option('--source <name>', 'Index a specific source only')
240
+ .option('--force', 'Ignore mtime cache and re-index everything', false)
241
+ .action(async (opts) => {
242
+ try {
243
+ const stats = await indexSources({
244
+ sourceName: opts.source,
245
+ force: opts.force,
246
+ });
247
+
248
+ console.log('\nIndexing complete:');
249
+ console.log(` Sources processed: ${stats.sources_processed}`);
250
+ printIndexStats(stats);
251
+ } catch (err) {
252
+ console.error(`Error: ${err.message}`);
253
+ process.exit(1);
254
+ } finally {
255
+ closeDb();
256
+ }
257
+ });
258
+
259
+ // --- update ---
260
+ program
261
+ .command('update')
262
+ .description('Fetch and re-index stale sources (or all with --force)')
263
+ .option('--source <name>', 'Update a specific source only')
264
+ .option('--force', 'Re-index regardless of staleness', false)
265
+ .action(async (opts) => {
266
+ try {
267
+ let sources;
268
+
269
+ if (opts.source) {
270
+ const source = getSource(opts.source);
271
+ if (!source) {
272
+ console.error(`Source not found: ${opts.source}`);
273
+ process.exit(1);
274
+ }
275
+ if (!source.enabled) {
276
+ console.error(`Source "${opts.source}" is disabled.`);
277
+ process.exit(1);
278
+ }
279
+ sources = [source];
280
+ } else if (opts.force) {
281
+ sources = listSources().filter(s => s.enabled);
282
+ } else {
283
+ sources = getStaleSources(24 * 60 * 60 * 1000);
284
+ }
285
+
286
+ if (sources.length === 0) {
287
+ console.log('All sources are up to date. Use --force to re-index anyway.');
288
+ return;
289
+ }
290
+
291
+ console.log(`Updating ${sources.length} source(s)...\n`);
292
+
293
+ for (const source of sources) {
294
+ console.log(`Updating: ${source.name}`);
295
+ const stats = await indexSources({
296
+ sourceName: source.name,
297
+ force: opts.force,
298
+ });
299
+ printIndexStats(stats);
300
+ console.log('');
301
+ }
302
+
303
+ console.log('Update complete.');
304
+ } catch (err) {
305
+ console.error(`Error: ${err.message}`);
306
+ process.exit(1);
307
+ } finally {
308
+ closeDb();
309
+ }
310
+ });
311
+
312
+ // --- search ---
313
+ program
314
+ .command('search <query>')
315
+ .description('Search indexed hooks')
316
+ .option('--type <type>', 'Filter by hook type')
317
+ .option('--source <name>', 'Filter by source name')
318
+ .option('--limit <n>', 'Max results', '20')
319
+ .option('--include-removed', 'Include removed hooks', false)
320
+ .action((query, opts) => {
321
+ try {
322
+ const results = searchHooks(query, {
323
+ type: opts.type,
324
+ source: opts.source,
325
+ includeRemoved: opts.includeRemoved,
326
+ limit: parseInt(opts.limit, 10),
327
+ });
328
+
329
+ if (results.length === 0) {
330
+ console.log(`No hooks found matching "${query}".`);
331
+ return;
332
+ }
333
+
334
+ console.log(`\nFound ${results.length} hook(s) matching "${query}":\n`);
335
+
336
+ for (const h of results) {
337
+ console.log(` ${h.name}`);
338
+ console.log(` Type: ${h.type} | Source: ${h.source_name}`);
339
+ console.log(` File: ${h.file_path}:${h.line_number}`);
340
+ if (h.is_dynamic) console.log(' Dynamic: yes');
341
+ if (h.status === 'removed') console.log(' Status: REMOVED');
342
+ if (h.class_name) console.log(` Class: ${h.class_name}`);
343
+ if (h.php_function) console.log(` Function: ${h.php_function}()`);
344
+ if (h.params) console.log(` Params: ${h.params}`);
345
+ if (h.inferred_description) console.log(` Description: ${h.inferred_description}`);
346
+ if (h.docblock) console.log(` Docblock: ${h.docblock.replace(/\n/g, '\n ')}`);
347
+ console.log(` ID: ${h.id}`);
348
+ console.log('');
349
+ }
350
+ } catch (err) {
351
+ console.error(`Error: ${err.message}`);
352
+ process.exit(1);
353
+ } finally {
354
+ closeDb();
355
+ }
356
+ });
357
+
358
+ // --- search-blocks ---
359
+ program
360
+ .command('search-blocks <query>')
361
+ .description('Search block registrations and WP JS API usages')
362
+ .option('--limit <n>', 'Max results per category', '20')
363
+ .action((query, opts) => {
364
+ try {
365
+ const { blocks, apis } = searchBlockApis(query, {
366
+ limit: parseInt(opts.limit, 10),
367
+ });
368
+
369
+ if (blocks.length === 0 && apis.length === 0) {
370
+ console.log(`No block registrations or API usages found matching "${query}".`);
371
+ return;
372
+ }
373
+
374
+ if (blocks.length > 0) {
375
+ console.log(`\nBlock Registrations (${blocks.length}):\n`);
376
+ for (const b of blocks) {
377
+ console.log(` ${b.block_name || 'unknown'}`);
378
+ console.log(` Source: ${b.source_name} | File: ${b.file_path}:${b.line_number}`);
379
+ if (b.block_title) console.log(` Title: ${b.block_title}`);
380
+ if (b.block_category) console.log(` Category: ${b.block_category}`);
381
+ if (b.code_context) console.log(` Context: ${b.code_context.split('\n').slice(0, 5).join('\n ')}`);
382
+ console.log('');
383
+ }
384
+ }
385
+
386
+ if (apis.length > 0) {
387
+ console.log(`API Usages (${apis.length}):\n`);
388
+ for (const a of apis) {
389
+ console.log(` ${a.api_call}`);
390
+ console.log(` Source: ${a.source_name} | File: ${a.file_path}:${a.line_number}`);
391
+ console.log(` Namespace: ${a.namespace} | Method: ${a.method}`);
392
+ if (a.code_context) console.log(` Context: ${a.code_context.split('\n').slice(0, 3).join('\n ')}`);
393
+ console.log('');
394
+ }
395
+ }
396
+ } catch (err) {
397
+ console.error(`Error: ${err.message}`);
398
+ process.exit(1);
399
+ } finally {
400
+ closeDb();
401
+ }
402
+ });
403
+
404
+ // --- search-docs ---
405
+ program
406
+ .command('search-docs <query>')
407
+ .description('Search indexed WordPress documentation')
408
+ .option('--type <type>', 'Filter by doc type (guide, tutorial, reference, api, howto, faq, general)')
409
+ .option('--category <cat>', 'Filter by category (block-editor, plugins, rest-api, wp-cli, admin)')
410
+ .option('--source <name>', 'Filter by source name')
411
+ .option('--limit <n>', 'Max results', '20')
412
+ .action((query, opts) => {
413
+ try {
414
+ const results = searchDocs(query, {
415
+ doc_type: opts.type,
416
+ category: opts.category,
417
+ source: opts.source,
418
+ limit: parseInt(opts.limit, 10),
419
+ });
420
+
421
+ if (results.length === 0) {
422
+ console.log(`No documentation found matching "${query}".`);
423
+ return;
424
+ }
425
+
426
+ console.log(`\nFound ${results.length} doc(s) matching "${query}":\n`);
427
+
428
+ for (const d of results) {
429
+ console.log(` ${d.title}`);
430
+ console.log(` Type: ${d.doc_type} | Category: ${d.category || 'general'} | Source: ${d.source_name}`);
431
+ console.log(` Slug: ${d.slug}`);
432
+ if (d.subcategory) console.log(` Subcategory: ${d.subcategory}`);
433
+ if (d.description) console.log(` Description: ${d.description.slice(0, 150)}${d.description.length > 150 ? '...' : ''}`);
434
+ console.log(` ID: ${d.id}`);
435
+ console.log('');
436
+ }
437
+ } catch (err) {
438
+ console.error(`Error: ${err.message}`);
439
+ process.exit(1);
440
+ } finally {
441
+ closeDb();
442
+ }
443
+ });
444
+
445
+ // --- validate ---
446
+ program
447
+ .command('validate <hook-name>')
448
+ .description('Validate if a hook name exists (exit code 0=valid, 1=not found)')
449
+ .action((hookName) => {
450
+ try {
451
+ const result = validateHook(hookName);
452
+
453
+ if (result.status === 'VALID') {
454
+ console.log(`VALID — "${hookName}" found in ${result.hooks.length} location(s):`);
455
+ for (const h of result.hooks) {
456
+ console.log(` ${h.source_name}: ${h.file_path}:${h.line_number} (${h.type})`);
457
+ }
458
+ process.exit(0);
459
+ }
460
+
461
+ if (result.status === 'REMOVED') {
462
+ console.log(`REMOVED — "${hookName}" was found but has been removed.`);
463
+ process.exit(1);
464
+ }
465
+
466
+ console.log(`NOT FOUND — "${hookName}" does not exist in any indexed source.`);
467
+ if (result.similar.length > 0) {
468
+ console.log('\nDid you mean:');
469
+ for (const s of result.similar) {
470
+ console.log(` ${s.name} (${s.type}) [${s.source_name}]`);
471
+ }
472
+ }
473
+ process.exit(1);
474
+ } catch (err) {
475
+ console.error(`Error: ${err.message}`);
476
+ process.exit(1);
477
+ } finally {
478
+ closeDb();
479
+ }
480
+ });
481
+
482
+ // --- stats ---
483
+ program
484
+ .command('stats')
485
+ .description('Show indexing statistics')
486
+ .action(() => {
487
+ try {
488
+ const stats = getStats();
489
+
490
+ console.log('\nOverall Statistics:');
491
+ console.log(` Sources: ${stats.totals.sources}`);
492
+ console.log(` Active hooks: ${stats.totals.active_hooks}`);
493
+ console.log(` Removed hooks: ${stats.totals.removed_hooks}`);
494
+ console.log(` Block registrations: ${stats.totals.block_registrations}`);
495
+ console.log(` API usages: ${stats.totals.api_usages}`);
496
+ console.log(` Documentation pages: ${stats.totals.docs}`);
497
+
498
+ if (stats.per_source.length > 0) {
499
+ console.log('\nPer Source:');
500
+ console.log(` ${'Name'.padEnd(25)} ${'Type'.padEnd(8)} ${'Hooks'.padEnd(8)} ${'Removed'.padEnd(10)} ${'Blocks'.padEnd(8)} ${'APIs'.padEnd(8)} ${'Docs'.padEnd(8)} Files`);
501
+ console.log(' ' + '-'.repeat(90));
502
+ for (const s of stats.per_source) {
503
+ console.log(
504
+ ` ${s.name.padEnd(25)} ${(s.content_type || 'source').padEnd(8)} ${String(s.hooks).padEnd(8)} ${String(s.removed_hooks).padEnd(10)} ${String(s.blocks).padEnd(8)} ${String(s.apis).padEnd(8)} ${String(s.docs).padEnd(8)} ${s.files}`
505
+ );
506
+ }
507
+ }
508
+ console.log('');
509
+ } catch (err) {
510
+ console.error(`Error: ${err.message}`);
511
+ process.exit(1);
512
+ } finally {
513
+ closeDb();
514
+ }
515
+ });
516
+
517
+ // --- rebuild-index ---
518
+ program
519
+ .command('rebuild-index')
520
+ .description('Rebuild FTS indexes (recovery for out-of-sync full-text search)')
521
+ .action(() => {
522
+ try {
523
+ rebuildFtsIndex();
524
+ console.log('FTS indexes rebuilt successfully (hooks, blocks, APIs, docs).');
525
+ } catch (err) {
526
+ console.error(`Error: ${err.message}`);
527
+ process.exit(1);
528
+ } finally {
529
+ closeDb();
530
+ }
531
+ });
532
+
533
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "wp-devdocs-mcp",
3
+ "version": "1.1.1",
4
+ "description": "MCP server that indexes WordPress hooks, blocks, APIs, and official documentation — exposing them as queryable tools to AI assistants",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20.0.0"
8
+ },
9
+ "bin": {
10
+ "wp-hooks": "./bin/wp-hooks.js",
11
+ "wp-devdocs-mcp": "./src/mcp-entry.js",
12
+ "wp-mcp": "./src/mcp-entry.js"
13
+ },
14
+ "files": [
15
+ "src/",
16
+ "bin/",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "start": "node src/mcp-entry.js",
22
+ "test": "node test/docs-test.js && node test/search-test.js",
23
+ "lint": "eslint src/ bin/"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/pluginslab/wp-devdocs-mcp.git"
28
+ },
29
+ "homepage": "https://github.com/pluginslab/wp-devdocs-mcp#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/pluginslab/wp-devdocs-mcp/issues"
32
+ },
33
+ "author": "Marcel Schmitz",
34
+ "keywords": [
35
+ "mcp",
36
+ "wordpress",
37
+ "hooks",
38
+ "devdocs",
39
+ "ai",
40
+ "claude",
41
+ "model-context-protocol",
42
+ "gutenberg",
43
+ "blocks",
44
+ "wp-cli"
45
+ ],
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.12.1",
48
+ "better-sqlite3": "^11.8.2",
49
+ "commander": "^13.1.0",
50
+ "dotenv": "^17.3.1",
51
+ "fast-glob": "^3.3.3",
52
+ "simple-git": "^3.27.0",
53
+ "zod": "^3.24.2"
54
+ },
55
+ "devDependencies": {
56
+ "@anthropic-ai/sdk": "^0.78.0",
57
+ "@eslint/js": "^9.39.3",
58
+ "@google/generative-ai": "^0.24.1",
59
+ "eslint": "^9.39.3"
60
+ },
61
+ "license": "MIT"
62
+ }
@@ -0,0 +1,22 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+
4
+ export const BASE_DIR = join(homedir(), '.wp-devdocs-mcp');
5
+ export const DB_PATH = join(BASE_DIR, 'hooks.db');
6
+ export const CACHE_DIR = join(BASE_DIR, 'cache');
7
+ export const SOURCES_DIR = join(BASE_DIR, 'sources');
8
+
9
+ export const DOC_TYPES = {
10
+ GUIDE: 'guide',
11
+ TUTORIAL: 'tutorial',
12
+ REFERENCE: 'reference',
13
+ API: 'api',
14
+ HOWTO: 'howto',
15
+ FAQ: 'faq',
16
+ GENERAL: 'general',
17
+ };
18
+
19
+ export const CONTENT_TYPES = {
20
+ SOURCE: 'source',
21
+ DOCS: 'docs',
22
+ };