niranzwp 0.1.0 → 0.7.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/README.md +5 -5
- package/bin/niranzwp.js +548 -23
- package/lib/cache.js +122 -0
- package/lib/errors.js +78 -0
- package/lib/mcp.js +185 -0
- package/lib/oauth.js +196 -0
- package/lib/schema.js +94 -0
- package/lib/store.js +49 -11
- package/lib/wp.js +298 -17
- package/package.json +23 -1
package/README.md
CHANGED
|
@@ -31,13 +31,13 @@ client sites, you usually do not have that.
|
|
|
31
31
|
`niranzwp probe` reports which tiers a site offers before you connect.
|
|
32
32
|
|
|
33
33
|
```
|
|
34
|
-
$ niranzwp probe
|
|
35
|
-
|
|
36
|
-
https://
|
|
34
|
+
$ niranzwp probe example.com
|
|
35
|
+
Example Magazine -- a WordPress site
|
|
36
|
+
https://example.com
|
|
37
37
|
Tier 1 (app passwords): yes
|
|
38
38
|
Tier 2 (abilities): yes
|
|
39
|
-
MCP endpoint:
|
|
40
|
-
OAuth server:
|
|
39
|
+
MCP endpoint: no
|
|
40
|
+
OAuth server: no
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
|
package/bin/niranzwp.js
CHANGED
|
@@ -1,24 +1,63 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loginWithAppPassword } from '../lib/auth.js';
|
|
3
|
-
import { saveProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
|
|
4
|
-
import {
|
|
3
|
+
import { saveProfile, saveOAuthProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
|
|
4
|
+
import { discover, registerClient, startDeviceFlow, pollForToken } from '../lib/oauth.js';
|
|
5
|
+
import { CliError } from '../lib/errors.js';
|
|
6
|
+
import * as cache from '../lib/cache.js';
|
|
7
|
+
import { listServers, pickEndpoint, McpClient, textOf } from '../lib/mcp.js';
|
|
8
|
+
import { validate, applyDefaults } from '../lib/schema.js';
|
|
9
|
+
import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, describeAbility, isReadOnly, methodFor, contract, checkCompat,
|
|
10
|
+
listItems, getItem, createItem, updateItem, deleteItem, getSettings, updateSettings, totalOf,
|
|
11
|
+
introspectAppPassword, revokeAppPassword, WpError, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT } from '../lib/wp.js';
|
|
5
12
|
import { readFileSync } from 'node:fs';
|
|
6
13
|
|
|
7
|
-
const
|
|
14
|
+
const VERSION = '0.6.0';
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
const USAGE = `NiranzWP CLI v${VERSION} -- a CLI for WordPress
|
|
17
|
+
Built by Niranjan -- https://niranz.dev
|
|
18
|
+
|
|
19
|
+
niranzwp auth login <url> [--name <profile>] [--no-open] [--app-password]
|
|
10
20
|
niranzwp auth status [--site <profile>]
|
|
11
|
-
niranzwp auth logout <profile>
|
|
21
|
+
niranzwp auth logout <profile> [--local] revoke on the site too, unless --local
|
|
12
22
|
|
|
13
23
|
niranzwp probe <url> what does this site support? (no auth)
|
|
14
24
|
niranzwp discover [--site <profile>] list abilities this site exposes
|
|
15
25
|
|
|
16
|
-
|
|
26
|
+
--- no plugin needed, works on any WordPress 5.6+ ---
|
|
27
|
+
niranzwp post list|get|create|update|delete
|
|
28
|
+
niranzwp page list|get|create|update|delete
|
|
29
|
+
niranzwp media list [--missing-alt]
|
|
30
|
+
niranzwp media set-alt <id> "<text>"
|
|
31
|
+
niranzwp user list
|
|
32
|
+
niranzwp settings get|set <key> <value>
|
|
33
|
+
|
|
34
|
+
niranzwp seo audit site-wide SEO gaps, ranked by severity
|
|
35
|
+
niranzwp seo missing <field> list posts missing description|title|focus|thumbnail|alt
|
|
36
|
+
niranzwp geo check AI crawler access, llms.txt, sitemap
|
|
37
|
+
niranzwp geo llms-txt [--write] generate llms.txt for AI answer engines
|
|
38
|
+
|
|
17
39
|
niranzwp run <ability> [--input '<json>' | --file <path>]
|
|
18
40
|
|
|
41
|
+
seo and geo commands need the NiranzWP plugin on the site.
|
|
42
|
+
|
|
43
|
+
niranzwp describe <ability> show an ability's schema and annotations
|
|
44
|
+
|
|
45
|
+
niranzwp sites list connected sites
|
|
46
|
+
niranzwp doctor [--offline] check this install and every connection
|
|
47
|
+
niranzwp cache clear drop cached ability metadata
|
|
48
|
+
|
|
49
|
+
niranzwp mcp servers MCP servers this site exposes
|
|
50
|
+
niranzwp mcp tools tools available over MCP
|
|
51
|
+
niranzwp mcp call <tool> [--input] call a tool over MCP
|
|
52
|
+
|
|
19
53
|
Options
|
|
20
|
-
--
|
|
21
|
-
--
|
|
54
|
+
--all run across every connected site (seo, geo, discover)
|
|
55
|
+
--site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
|
|
56
|
+
--json raw JSON output
|
|
57
|
+
--yes approve a write ability (required for anything not read-only)
|
|
58
|
+
--timeout <ms> request timeout (default 30000)
|
|
59
|
+
--max-output <bytes> response budget (default 1048576)
|
|
60
|
+
--version print version
|
|
22
61
|
`;
|
|
23
62
|
|
|
24
63
|
function parseArgs(argv) {
|
|
@@ -56,6 +95,21 @@ function resolveProfile(flags) {
|
|
|
56
95
|
return p;
|
|
57
96
|
}
|
|
58
97
|
|
|
98
|
+
// --all turns any site-scoped command into a fleet operation. Nothing else
|
|
99
|
+
// in the WordPress CLI space does this, and it is the whole point of the tool
|
|
100
|
+
// for anyone running more than one site.
|
|
101
|
+
function targets(flags) {
|
|
102
|
+
if (flags.all !== true) return [resolveProfile(flags)];
|
|
103
|
+
const all = listProfiles().map((p) => getProfile(p.name)).filter(Boolean);
|
|
104
|
+
if (!all.length) die('no sites connected. Run: niranzwp auth login <url>');
|
|
105
|
+
return all;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const reqOpts = (flags) => ({
|
|
109
|
+
timeout: Number(flags.timeout || DEFAULT_TIMEOUT_MS),
|
|
110
|
+
maxOutput: Number(flags['max-output'] || DEFAULT_MAX_OUTPUT),
|
|
111
|
+
});
|
|
112
|
+
|
|
59
113
|
const out = (flags, value, pretty) => {
|
|
60
114
|
if (flags.json || !pretty) console.log(JSON.stringify(value, null, 2));
|
|
61
115
|
else pretty(value);
|
|
@@ -65,12 +119,45 @@ async function main() {
|
|
|
65
119
|
const { _: pos, flags } = parseArgs(process.argv.slice(2));
|
|
66
120
|
const [cmd, sub, ...rest] = pos;
|
|
67
121
|
|
|
122
|
+
if (flags.version || cmd === 'version') {
|
|
123
|
+
console.log(`NiranzWP CLI v${VERSION}`);
|
|
124
|
+
console.log('Built by Niranjan -- https://niranz.dev');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
68
128
|
if (!cmd || flags.help || cmd === 'help') { console.log(USAGE); return; }
|
|
69
129
|
|
|
70
130
|
if (cmd === 'auth' && sub === 'login') {
|
|
71
131
|
const url = rest[0];
|
|
72
132
|
if (!url) die('usage: niranzwp auth login <url>');
|
|
73
133
|
const site = normalizeSite(url);
|
|
134
|
+
|
|
135
|
+
// Prefer OAuth where the site offers it: nothing is stored that can be
|
|
136
|
+
// replayed, and access can be revoked server-side. --app-password
|
|
137
|
+
// forces the older path.
|
|
138
|
+
const meta = flags['app-password'] === true ? null : await discover(site);
|
|
139
|
+
|
|
140
|
+
if (meta?.device_authorization_endpoint) {
|
|
141
|
+
const name = flags.name || new URL(site).hostname.replace(/^www\./, '');
|
|
142
|
+
const clientId = await registerClient(meta);
|
|
143
|
+
const device = await startDeviceFlow(meta, clientId);
|
|
144
|
+
|
|
145
|
+
console.error('Open this page and enter the code:');
|
|
146
|
+
console.error(` ${device.verificationUri}`);
|
|
147
|
+
console.error(` code: ${device.userCode}`);
|
|
148
|
+
console.error(`\nExpires in ${Math.round(device.expiresIn / 60)} minutes. Waiting...`);
|
|
149
|
+
|
|
150
|
+
const tokens = await pollForToken(meta, clientId, device, {
|
|
151
|
+
timeoutMs: Number(flags.timeout || device.expiresIn * 1000),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
saveOAuthProfile(name, { siteUrl: site, clientId, tokens });
|
|
155
|
+
console.log(`Connected "${name}" -> ${site} via OAuth`);
|
|
156
|
+
console.log(`Tokens stored in ${storageKind()}; they refresh automatically.`);
|
|
157
|
+
console.log('Try: niranzwp discover');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
74
161
|
const creds = await loginWithAppPassword(site, { open: flags.open !== false });
|
|
75
162
|
const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
|
|
76
163
|
saveProfile(name, creds);
|
|
@@ -103,11 +190,53 @@ async function main() {
|
|
|
103
190
|
}
|
|
104
191
|
|
|
105
192
|
if (cmd === 'auth' && sub === 'logout') {
|
|
106
|
-
const name = rest[0];
|
|
107
|
-
if (!name) die('usage: niranzwp auth logout <profile>');
|
|
193
|
+
const name = rest[0] || flags.site;
|
|
194
|
+
if (!name) die('usage: niranzwp auth logout <profile> [--local]');
|
|
195
|
+
|
|
196
|
+
const prof = getProfile(name);
|
|
197
|
+
if (!prof) {
|
|
198
|
+
deleteProfile(name);
|
|
199
|
+
console.log(`Removed "${name}" locally (no stored credential to revoke).`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Removing the local copy is not disconnecting: the credential stays
|
|
204
|
+
// valid on the site until it is revoked there. Do both by default.
|
|
205
|
+
let revoked = null;
|
|
206
|
+
if (flags.local !== true) {
|
|
207
|
+
try {
|
|
208
|
+
if ('oauth' === prof.auth) {
|
|
209
|
+
const meta = await discover(prof.siteUrl);
|
|
210
|
+
if (meta?.revocation_endpoint) {
|
|
211
|
+
await fetch(meta.revocation_endpoint, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
214
|
+
body: new URLSearchParams({
|
|
215
|
+
token: prof.tokens.refreshToken || prof.tokens.accessToken,
|
|
216
|
+
client_id: prof.clientId,
|
|
217
|
+
}).toString(),
|
|
218
|
+
});
|
|
219
|
+
revoked = 'oauth grant';
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
const me = await introspectAppPassword(prof, reqOpts(flags));
|
|
223
|
+
if (me?.uuid) {
|
|
224
|
+
await revokeAppPassword(prof, me.uuid, reqOpts(flags));
|
|
225
|
+
revoked = `application password "${me.name || name}"`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`warning: could not revoke on the site (${e.message})`);
|
|
230
|
+
console.error('The local credential is still being removed. Revoke it under Users -> Profile -> Application Passwords.');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
108
234
|
deleteProfile(name);
|
|
109
|
-
console.log(`
|
|
110
|
-
console.log(
|
|
235
|
+
console.log(`Disconnected "${name}" from ${prof.siteUrl}.`);
|
|
236
|
+
console.log(revoked ? `Revoked ${revoked} on the site.` : 'Local credential removed.');
|
|
237
|
+
if (flags.local === true) {
|
|
238
|
+
console.log('--local was passed, so nothing was revoked on the site.');
|
|
239
|
+
}
|
|
111
240
|
return;
|
|
112
241
|
}
|
|
113
242
|
|
|
@@ -138,20 +267,388 @@ async function main() {
|
|
|
138
267
|
return;
|
|
139
268
|
}
|
|
140
269
|
|
|
141
|
-
|
|
270
|
+
// --- Tier 1: core REST, no plugin -------------------------------------
|
|
271
|
+
|
|
272
|
+
if (['post', 'page'].includes(cmd)) {
|
|
142
273
|
const p = resolveProfile(flags);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
274
|
+
const title = (r) => r.title?.raw ?? r.title?.rendered ?? '';
|
|
275
|
+
|
|
276
|
+
if (sub === 'list' || sub === undefined) {
|
|
277
|
+
const { data, headers } = await listItems(p, cmd, {
|
|
278
|
+
status: flags.status || 'publish',
|
|
279
|
+
perPage: Number(flags.limit || 20),
|
|
280
|
+
page: Number(flags.page || 1),
|
|
281
|
+
search: flags.search === true ? undefined : flags.search,
|
|
282
|
+
fields: 'id,date,status,link,title',
|
|
283
|
+
}, reqOpts(flags));
|
|
284
|
+
out(flags, data, (rows) => {
|
|
285
|
+
console.log(`${rows.length} of ${totalOf(headers) ?? '?'} ${cmd}s (${flags.status || 'publish'})`);
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
console.log(` ${String(r.id).padEnd(8)}${r.date.slice(0, 10)} ${title(r).slice(0, 58)}`);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (sub === 'get') {
|
|
294
|
+
if (!rest[0]) die(`usage: niranzwp ${cmd} get <id>`);
|
|
295
|
+
out(flags, await getItem(p, cmd, rest[0], reqOpts(flags)), null);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (sub === 'create') {
|
|
300
|
+
const body = {
|
|
301
|
+
title: flags.title === true ? undefined : flags.title,
|
|
302
|
+
content: flags.content === true ? undefined : flags.content,
|
|
303
|
+
excerpt: flags.excerpt === true ? undefined : flags.excerpt,
|
|
304
|
+
// Draft unless publishing is asked for explicitly.
|
|
305
|
+
status: flags.status || 'draft',
|
|
306
|
+
};
|
|
307
|
+
if (!body.title) die('--title is required');
|
|
308
|
+
const r = await createItem(p, cmd, body, reqOpts(flags));
|
|
309
|
+
console.log(`created ${cmd} ${r.id} (${r.status}) -- ${r.link}`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (sub === 'update') {
|
|
314
|
+
const id = rest[0];
|
|
315
|
+
if (!id) die(`usage: niranzwp ${cmd} update <id> [--title x] [--content y] [--status z]`);
|
|
316
|
+
const body = {};
|
|
317
|
+
for (const k of ['title', 'content', 'excerpt', 'status', 'slug']) {
|
|
318
|
+
if (typeof flags[k] === 'string') body[k] = flags[k];
|
|
319
|
+
}
|
|
320
|
+
if (!Object.keys(body).length) die('nothing to update -- pass --title, --content, --excerpt, --status or --slug');
|
|
321
|
+
if (flags.yes !== true) {
|
|
322
|
+
throw new CliError('approval_required', `This edits ${cmd} ${id} on ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
323
|
+
}
|
|
324
|
+
const r = await updateItem(p, cmd, id, body, reqOpts(flags));
|
|
325
|
+
console.log(`updated ${cmd} ${r.id} (${r.status})`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (sub === 'delete') {
|
|
330
|
+
const id = rest[0];
|
|
331
|
+
if (!id) die(`usage: niranzwp ${cmd} delete <id> [--force]`);
|
|
332
|
+
if (flags.yes !== true) {
|
|
333
|
+
throw new CliError('approval_required',
|
|
334
|
+
flags.force === true
|
|
335
|
+
? `This PERMANENTLY deletes ${cmd} ${id} on ${p.siteUrl}.`
|
|
336
|
+
: `This moves ${cmd} ${id} to trash on ${p.siteUrl}.`,
|
|
337
|
+
{ hint: 'Re-run with --yes.' });
|
|
338
|
+
}
|
|
339
|
+
const r = await deleteItem(p, cmd, id, { force: flags.force === true }, reqOpts(flags));
|
|
340
|
+
console.log(flags.force === true ? `deleted ${cmd} ${id}` : `trashed ${cmd} ${id} (restorable)`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
die(`usage: niranzwp ${cmd} list|get|create|update|delete`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (cmd === 'media') {
|
|
348
|
+
const p = resolveProfile(flags);
|
|
349
|
+
|
|
350
|
+
if (sub === 'list' || sub === undefined) {
|
|
351
|
+
const { data, headers } = await listItems(p, 'media', {
|
|
352
|
+
perPage: Number(flags.limit || 20),
|
|
353
|
+
page: Number(flags.page || 1),
|
|
354
|
+
search: flags.search === true ? undefined : flags.search,
|
|
355
|
+
fields: 'id,date,alt_text,mime_type,source_url,title',
|
|
356
|
+
extra: { media_type: 'image' },
|
|
357
|
+
}, reqOpts(flags));
|
|
358
|
+
const rows = flags['missing-alt'] === true ? data.filter((m) => !m.alt_text) : data;
|
|
359
|
+
out(flags, rows, (list) => {
|
|
360
|
+
console.log(`${list.length} of ${totalOf(headers) ?? '?'} images${flags['missing-alt'] === true ? ' (missing alt on this page)' : ''}`);
|
|
361
|
+
for (const m of list) {
|
|
362
|
+
console.log(` ${String(m.id).padEnd(8)}${(m.alt_text ? 'alt' : '---').padEnd(5)}${(m.source_url || '').split('/').pop().slice(0, 52)}`);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (sub === 'set-alt') {
|
|
369
|
+
const [id, ...words] = rest;
|
|
370
|
+
const alt = words.join(' ');
|
|
371
|
+
if (!id || !alt) die('usage: niranzwp media set-alt <id> "<alt text>"');
|
|
372
|
+
if (flags.yes !== true) {
|
|
373
|
+
throw new CliError('approval_required', `This sets alt text on media ${id} at ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
374
|
+
}
|
|
375
|
+
const r = await updateItem(p, 'media', id, { alt_text: alt }, reqOpts(flags));
|
|
376
|
+
console.log(`media ${r.id} alt set to: ${r.alt_text}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
die('usage: niranzwp media list|set-alt');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (cmd === 'user' && (sub === 'list' || sub === undefined)) {
|
|
384
|
+
const p = resolveProfile(flags);
|
|
385
|
+
const { data, headers } = await listItems(p, 'user', {
|
|
386
|
+
perPage: Number(flags.limit || 20),
|
|
387
|
+
fields: 'id,name,slug,roles,email',
|
|
388
|
+
}, reqOpts(flags));
|
|
149
389
|
out(flags, data, (rows) => {
|
|
150
|
-
|
|
151
|
-
|
|
390
|
+
console.log(`${rows.length} of ${totalOf(headers) ?? '?'} users`);
|
|
391
|
+
for (const u of rows) {
|
|
392
|
+
console.log(` ${String(u.id).padEnd(6)}${(u.name || '').padEnd(26)}${(u.roles || []).join(',')}`);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (cmd === 'settings') {
|
|
399
|
+
const p = resolveProfile(flags);
|
|
400
|
+
|
|
401
|
+
if (sub === 'get' || sub === undefined) {
|
|
402
|
+
const s = await getSettings(p, reqOpts(flags));
|
|
403
|
+
out(flags, s, (o) => {
|
|
404
|
+
for (const [k, v] of Object.entries(o)) {
|
|
405
|
+
console.log(` ${k.padEnd(28)}${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (sub === 'set') {
|
|
412
|
+
const [key, ...words] = rest;
|
|
413
|
+
const value = words.join(' ');
|
|
414
|
+
if (!key || !value) die('usage: niranzwp settings set <key> <value>');
|
|
415
|
+
if (flags.yes !== true) {
|
|
416
|
+
throw new CliError('approval_required', `This changes "${key}" on ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
417
|
+
}
|
|
418
|
+
const before = (await getSettings(p, reqOpts(flags)))[key];
|
|
419
|
+
const after = await updateSettings(p, { [key]: value }, reqOpts(flags));
|
|
420
|
+
console.log(`${key}\n before: ${JSON.stringify(before)}\n after: ${JSON.stringify(after[key])}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
die('usage: niranzwp settings get|set <key> <value>');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (cmd === 'mcp') {
|
|
428
|
+
const p = resolveProfile(flags);
|
|
429
|
+
const servers = await listServers(p.siteUrl);
|
|
430
|
+
if (!servers.length) die(`${p.siteUrl} exposes no MCP servers.`);
|
|
431
|
+
|
|
432
|
+
if (sub === 'servers') {
|
|
433
|
+
out(flags, servers, (rows) => {
|
|
434
|
+
for (const s of rows) console.log(` ${s.name.padEnd(30)}${s.url}`);
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const endpoint = flags.endpoint || pickEndpoint(servers, { oauth: p.auth === 'oauth' });
|
|
440
|
+
const client = new McpClient(endpoint, {
|
|
441
|
+
token: p.auth === 'oauth' ? p.tokens.accessToken : undefined,
|
|
442
|
+
basic: p.auth !== 'oauth' && p.password
|
|
443
|
+
? Buffer.from(`${p.user}:${p.password.replace(/\s+/g, '')}`, 'utf8').toString('base64')
|
|
444
|
+
: undefined,
|
|
445
|
+
timeout: Number(flags.timeout || DEFAULT_TIMEOUT_MS),
|
|
446
|
+
});
|
|
447
|
+
await client.initialize();
|
|
448
|
+
|
|
449
|
+
if (sub === 'tools') {
|
|
450
|
+
const tools = await client.listTools();
|
|
451
|
+
out(flags, tools, (rows) => {
|
|
452
|
+
console.log(`${rows.length} tools on ${endpoint}`);
|
|
453
|
+
for (const t of rows) console.log(` ${t.name}${t.description ? ` -- ${String(t.description).split('\n')[0].slice(0, 70)}` : ''}`);
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (sub === 'call') {
|
|
459
|
+
const tool = rest[0];
|
|
460
|
+
if (!tool) die('usage: niranzwp mcp call <tool> [--input <json>|--file <path>]');
|
|
461
|
+
let args = {};
|
|
462
|
+
if (flags.file) args = JSON.parse(readFileSync(flags.file, 'utf8'));
|
|
463
|
+
else if (typeof flags.input === 'string') args = JSON.parse(flags.input);
|
|
464
|
+
const r = await client.callTool(tool, args);
|
|
465
|
+
console.log(textOf(r));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
die('usage: niranzwp mcp servers|tools|call <tool>');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (cmd === 'cache' && sub === 'clear') {
|
|
473
|
+
cache.clear();
|
|
474
|
+
console.log(`Cleared ${cache.dir()}`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (cmd === 'sites') {
|
|
479
|
+
const all = listProfiles();
|
|
480
|
+
if (!all.length) { console.log('No sites connected. Run: niranzwp auth login <url>'); return; }
|
|
481
|
+
out(flags, all, (rows) => {
|
|
152
482
|
for (const r of rows) {
|
|
153
|
-
|
|
483
|
+
const has = Boolean(getProfile(r.name));
|
|
484
|
+
console.log(` ${has ? ' ' : '!'} ${r.name.padEnd(28)}${r.siteUrl}${has ? '' : ' (credential missing)'}`);
|
|
485
|
+
}
|
|
486
|
+
console.log(`\n${rows.length} site${rows.length === 1 ? '' : 's'} -- storage: ${storageKind()}`);
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (cmd === 'doctor') {
|
|
492
|
+
const checks = [];
|
|
493
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
494
|
+
checks.push({
|
|
495
|
+
ok: nodeMajor >= 22,
|
|
496
|
+
label: 'Node 22+',
|
|
497
|
+
detail: `running ${process.versions.node}`,
|
|
498
|
+
});
|
|
499
|
+
checks.push({
|
|
500
|
+
ok: storageKind().includes('Keychain'),
|
|
501
|
+
warn: !storageKind().includes('Keychain'),
|
|
502
|
+
label: 'Credential storage',
|
|
503
|
+
detail: storageKind(),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const all = listProfiles();
|
|
507
|
+
checks.push({ ok: all.length > 0, label: 'Sites connected', detail: `${all.length}` });
|
|
508
|
+
|
|
509
|
+
if (flags.offline !== true) {
|
|
510
|
+
for (const prof of all) {
|
|
511
|
+
const info = await contract(prof.siteUrl);
|
|
512
|
+
if (!info) continue;
|
|
513
|
+
const problems = checkCompat(info);
|
|
514
|
+
checks.push({
|
|
515
|
+
ok: problems.length === 0,
|
|
516
|
+
label: `${prof.name} contract`,
|
|
517
|
+
detail: problems.length
|
|
518
|
+
? problems.join('; ')
|
|
519
|
+
: `WordPress ${info.wordpress_version}, REST contract v${info.rest_api_version}`,
|
|
520
|
+
});
|
|
154
521
|
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (flags.offline !== true) {
|
|
525
|
+
for (const prof of all) {
|
|
526
|
+
const full = getProfile(prof.name);
|
|
527
|
+
if (!full) {
|
|
528
|
+
checks.push({ ok: false, label: prof.name, detail: 'credential missing from keychain' });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const me = await whoami(full);
|
|
533
|
+
let abilities = 0;
|
|
534
|
+
try {
|
|
535
|
+
const list = await listAbilities(full);
|
|
536
|
+
abilities = (Array.isArray(list) ? list : list?.abilities || []).length;
|
|
537
|
+
} catch { /* tier 1 only */ }
|
|
538
|
+
checks.push({
|
|
539
|
+
ok: true,
|
|
540
|
+
label: prof.name,
|
|
541
|
+
detail: `${me.name} (id ${me.id}) -- ${abilities} abilities`,
|
|
542
|
+
});
|
|
543
|
+
} catch (e) {
|
|
544
|
+
checks.push({ ok: false, label: prof.name, detail: e.message });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
out(flags, checks, (rows) => {
|
|
550
|
+
for (const c of rows) {
|
|
551
|
+
const icon = c.ok ? 'ok ' : (c.warn ? 'warn' : 'FAIL');
|
|
552
|
+
console.log(` [${icon}] ${c.label.padEnd(24)}${c.detail}`);
|
|
553
|
+
}
|
|
554
|
+
const bad = rows.filter((c) => !c.ok && !c.warn).length;
|
|
555
|
+
console.log(`\n${bad === 0 ? 'All checks passed.' : `${bad} check(s) failed.`}`);
|
|
556
|
+
});
|
|
557
|
+
if (checks.some((c) => !c.ok && !c.warn)) process.exitCode = 1;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// --- SEO ---------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
if (cmd === 'seo' && sub === 'audit') {
|
|
564
|
+
for (const p of targets(flags)) {
|
|
565
|
+
const r = await runAbility(p, 'niranzwp/seo-audit', {
|
|
566
|
+
post_type: flags['post-type'] || 'post',
|
|
567
|
+
}, { ...reqOpts(flags), ability: await describeAbility(p, 'niranzwp/seo-audit', reqOpts(flags)).catch(() => null) })
|
|
568
|
+
.catch((e) => ({ _error: e.message }));
|
|
569
|
+
if (r._error) { console.error(`${p.name}: ${r._error}`); continue; }
|
|
570
|
+
out(flags, r, (a) => {
|
|
571
|
+
console.log(`${p.siteUrl} -- ${a.published} published ${a.post_type}`);
|
|
572
|
+
console.log(`SEO plugin: ${a.seo_plugin || 'none detected'}\n`);
|
|
573
|
+
if (!a.issues?.length) { console.log(' No issues found.'); return; }
|
|
574
|
+
const mark = { high: '!!', medium: ' !', warning: ' ?', low: ' ' };
|
|
575
|
+
for (const i of a.issues) {
|
|
576
|
+
console.log(`${mark[i.severity] ?? ' '} [${i.severity}] ${i.message}`);
|
|
577
|
+
}
|
|
578
|
+
console.log(`\n${a.issue_count} issue${a.issue_count === 1 ? '' : 's'}.\n`);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (cmd === 'seo' && sub === 'missing') {
|
|
585
|
+
const p = resolveProfile(flags);
|
|
586
|
+
const listMeta = await describeAbility(p, 'niranzwp/seo-list-missing', reqOpts(flags)).catch(() => null);
|
|
587
|
+
const r = await runAbility(p, 'niranzwp/seo-list-missing', {
|
|
588
|
+
field: rest[0] || flags.field || 'description',
|
|
589
|
+
post_type: flags['post-type'] || 'post',
|
|
590
|
+
limit: Number(flags.limit || 20),
|
|
591
|
+
offset: Number(flags.offset || 0),
|
|
592
|
+
}, { ...reqOpts(flags), ability: listMeta });
|
|
593
|
+
out(flags, r, (d) => {
|
|
594
|
+
console.log(`${d.count} missing "${d.field}" (offset ${d.offset})`);
|
|
595
|
+
for (const i of d.items) {
|
|
596
|
+
console.log(` ${String(i.id).padEnd(8)}${(i.date ?? '').padEnd(12)}${(i.title || '').slice(0, 58)}`);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// --- GEO ---------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
if (cmd === 'geo' && sub === 'check') {
|
|
605
|
+
for (const p of targets(flags)) {
|
|
606
|
+
const r = await runAbility(p, 'niranzwp/geo-check', {}, {
|
|
607
|
+
...reqOpts(flags),
|
|
608
|
+
ability: await describeAbility(p, 'niranzwp/geo-check', reqOpts(flags)).catch(() => null),
|
|
609
|
+
}).catch((e) => ({ _error: e.message }));
|
|
610
|
+
if (r._error) { console.error(`${p.name}: ${r._error}`); continue; }
|
|
611
|
+
out(flags, r, (g) => {
|
|
612
|
+
console.log(`${g.site}\n`);
|
|
613
|
+
console.log(` robots.txt ${g.robots_txt ? 'yes' : 'NO'}`);
|
|
614
|
+
console.log(` llms.txt ${g.llms_txt ? 'yes' : 'NO'}`);
|
|
615
|
+
console.log(` sitemap in robots ${g.sitemap_in_robots ? 'yes' : 'NO'}\n`);
|
|
616
|
+
console.log(' AI crawlers:');
|
|
617
|
+
for (const [agent, info] of Object.entries(g.ai_crawlers || {})) {
|
|
618
|
+
console.log(` ${info.blocked ? 'BLOCKED' : 'allowed'} ${agent.padEnd(20)} ${info.owner}`);
|
|
619
|
+
}
|
|
620
|
+
if (g.issues?.length) {
|
|
621
|
+
console.log('');
|
|
622
|
+
for (const i of g.issues) console.log(` [${i.severity}] ${i.message}`);
|
|
623
|
+
}
|
|
624
|
+
console.log('');
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (cmd === 'geo' && sub === 'llms-txt') {
|
|
631
|
+
const p = resolveProfile(flags);
|
|
632
|
+
const write = flags.write === true;
|
|
633
|
+
const llmsMeta = await describeAbility(p, 'niranzwp/geo-llms-txt', reqOpts(flags)).catch(() => null);
|
|
634
|
+
const r = await runAbility(p, 'niranzwp/geo-llms-txt', {
|
|
635
|
+
write,
|
|
636
|
+
limit: Number(flags.limit || 30),
|
|
637
|
+
}, { ...reqOpts(flags), ability: llmsMeta });
|
|
638
|
+
if (flags.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
639
|
+
console.log(r.content);
|
|
640
|
+
console.error(`\n${r.bytes} bytes -- ${r.written ? `written to ${r.url}` : 'not written (pass --write)'}`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (cmd === 'describe') {
|
|
645
|
+
const p = resolveProfile(flags);
|
|
646
|
+
if (!sub) die('usage: niranzwp describe <ability>');
|
|
647
|
+
const a = await describeAbility(p, sub, reqOpts(flags));
|
|
648
|
+
out(flags, a, (d) => {
|
|
649
|
+
console.log(`${d.name}\n ${d.description || ''}`);
|
|
650
|
+
console.log(` type: ${isReadOnly(d) ? 'read-only' : 'WRITE -- needs --yes'}`);
|
|
651
|
+
if (d.input_schema) console.log(` input: ${JSON.stringify(d.input_schema)}`);
|
|
155
652
|
});
|
|
156
653
|
return;
|
|
157
654
|
}
|
|
@@ -160,10 +657,33 @@ async function main() {
|
|
|
160
657
|
const p = resolveProfile(flags);
|
|
161
658
|
const ability = sub;
|
|
162
659
|
if (!ability) die('usage: niranzwp run <ability> [--input <json>|--file <path>]');
|
|
660
|
+
|
|
163
661
|
let input = {};
|
|
164
662
|
if (flags.file) input = JSON.parse(readFileSync(flags.file, 'utf8'));
|
|
165
663
|
else if (typeof flags.input === 'string') input = JSON.parse(flags.input);
|
|
166
|
-
|
|
664
|
+
|
|
665
|
+
// One describe serves two purposes: the write gate, and validating the
|
|
666
|
+
// input locally so a bad argument never reaches the site.
|
|
667
|
+
let meta = null;
|
|
668
|
+
try {
|
|
669
|
+
meta = await describeAbility(p, ability, reqOpts(flags));
|
|
670
|
+
} catch { /* fall through; run will report the real error */ }
|
|
671
|
+
|
|
672
|
+
if (meta && !isReadOnly(meta) && flags.yes !== true) {
|
|
673
|
+
throw new CliError('approval_required', `"${ability}" is a write ability.`, {
|
|
674
|
+
hint: 'Re-run with --yes to approve it.',
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (meta?.input_schema) {
|
|
679
|
+
input = applyDefaults(input, meta.input_schema);
|
|
680
|
+
const errors = validate(input, meta.input_schema);
|
|
681
|
+
if (errors.length) {
|
|
682
|
+
throw new CliError('usage_error', `Invalid input for ${ability}:\n - ${errors.join('\n - ')}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const result = await runAbility(p, ability, input, { ...reqOpts(flags), ability: meta });
|
|
167
687
|
console.log(JSON.stringify(result, null, 2));
|
|
168
688
|
return;
|
|
169
689
|
}
|
|
@@ -173,6 +693,11 @@ async function main() {
|
|
|
173
693
|
}
|
|
174
694
|
|
|
175
695
|
main().catch((e) => {
|
|
696
|
+
if (e instanceof CliError) {
|
|
697
|
+
console.error(`error [${e.code}]: ${e.message}`);
|
|
698
|
+
if (e.hint) console.error(`hint: ${e.hint}`);
|
|
699
|
+
process.exit(e.exitCode);
|
|
700
|
+
}
|
|
176
701
|
if (e instanceof WpError) {
|
|
177
702
|
die(`${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`);
|
|
178
703
|
}
|