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 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 uaestories.com
35
- UAE Stories -- UAE's first people centric magazine
36
- https://uaestories.com
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: yes
40
- OAuth server: yes
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 { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, WpError } from '../lib/wp.js';
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 USAGE = `niranzwp -- a CLI for WordPress
14
+ const VERSION = '0.6.0';
8
15
 
9
- niranzwp auth login <url> [--name <profile>] [--no-open]
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
- niranzwp post list [--status draft] [--search x] [--limit 10] [--page 1]
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
- --site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
21
- --json raw JSON output
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(`Removed "${name}" locally.`);
110
- console.log('Revoke the password itself under Users -> Profile -> Application Passwords in WordPress.');
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
- if (cmd === 'post' && sub === 'list') {
270
+ // --- Tier 1: core REST, no plugin -------------------------------------
271
+
272
+ if (['post', 'page'].includes(cmd)) {
142
273
  const p = resolveProfile(flags);
143
- const { data, headers } = await listPosts(p, {
144
- status: flags.status || 'publish',
145
- perPage: Number(flags.limit || 10),
146
- page: Number(flags.page || 1),
147
- search: flags.search === true ? undefined : flags.search,
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
- const total = headers.get('x-wp-total');
151
- console.log(`${rows.length} shown of ${total ?? '?'} (${flags.status || 'publish'})`);
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
- console.log(` ${String(r.id).padEnd(8)}${r.date.slice(0, 10)} ${(r.title?.raw ?? r.title?.rendered ?? '').slice(0, 60)}`);
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
- const result = await runAbility(p, ability, input);
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
  }