niranzwp 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niranjan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # NiranzWP CLI
2
+
3
+ A CLI for WordPress.
4
+
5
+ Works on **any** WordPress site through core Application Passwords — no plugin
6
+ required. Where a site also exposes the WordPress Abilities API (core, 6.9+),
7
+ `niranzwp` unlocks those abilities too.
8
+
9
+ ```sh
10
+ niranzwp probe example.com # what does this site support? (no auth)
11
+ niranzwp auth login example.com # browser consent -> credential in Keychain
12
+ niranzwp post list --status draft
13
+ niranzwp discover # abilities, if the site exposes any
14
+ ```
15
+
16
+ ## Why
17
+
18
+ `wp-cli` is the standard WordPress CLI, and it needs **SSH access to the
19
+ server**. On shared hosting, on managed platforms, or across a portfolio of
20
+ client sites, you usually do not have that.
21
+
22
+ `niranzwp` needs a **URL and one browser click**.
23
+
24
+ ## Two tiers
25
+
26
+ | | Requires | Gives you |
27
+ |---|---|---|
28
+ | **Tier 1** | nothing — core WordPress 5.6+ | posts, pages, media, users, taxonomies, comments, settings |
29
+ | **Tier 2** | an abilities provider on the site | whatever that site registers — PHP execution, filesystem, WP-CLI, … |
30
+
31
+ `niranzwp probe` reports which tiers a site offers before you connect.
32
+
33
+ ```
34
+ $ niranzwp probe uaestories.com
35
+ UAE Stories -- UAE's first people centric magazine
36
+ https://uaestories.com
37
+ Tier 1 (app passwords): yes
38
+ Tier 2 (abilities): yes
39
+ MCP endpoint: yes
40
+ OAuth server: yes
41
+ ```
42
+
43
+ Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
44
+ abilities through the core API works — Novamira, your own plugin, anything else.
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npm install -g niranzwp
50
+ ```
51
+
52
+ Requires Node 22+. No runtime dependencies.
53
+
54
+ ## Authentication
55
+
56
+ `niranzwp auth login` opens WordPress's own
57
+ `/wp-admin/authorize-application.php` consent screen. You approve there, and
58
+ WordPress redirects back to a temporary loopback listener with the credential.
59
+ Nothing is typed or pasted, and no password crosses a third party.
60
+
61
+ Credentials go to the **macOS Keychain** where available, otherwise to a
62
+ `0600` file under `~/.config/niranzwp`. Profile metadata (site URL, username)
63
+ lives in `~/.config/niranzwp/profiles.json`; passwords never do.
64
+
65
+ `niranzwp auth logout <profile>` removes the local copy. Revoke the password
66
+ itself under **Users → Profile → Application Passwords** in WordPress.
67
+
68
+ ## Status
69
+
70
+ Early. Tier 1 auth, `probe`, `post list`, `discover`, and `run` work today.
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
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';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const USAGE = `niranzwp -- a CLI for WordPress
8
+
9
+ niranzwp auth login <url> [--name <profile>] [--no-open]
10
+ niranzwp auth status [--site <profile>]
11
+ niranzwp auth logout <profile>
12
+
13
+ niranzwp probe <url> what does this site support? (no auth)
14
+ niranzwp discover [--site <profile>] list abilities this site exposes
15
+
16
+ niranzwp post list [--status draft] [--search x] [--limit 10] [--page 1]
17
+ niranzwp run <ability> [--input '<json>' | --file <path>]
18
+
19
+ Options
20
+ --site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
21
+ --json raw JSON output
22
+ `;
23
+
24
+ function parseArgs(argv) {
25
+ const args = { _: [], flags: {} };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a.startsWith('--')) {
29
+ const key = a.slice(2);
30
+ if (key.startsWith('no-')) { args.flags[key.slice(3)] = false; continue; }
31
+ const next = argv[i + 1];
32
+ if (next === undefined || next.startsWith('--')) { args.flags[key] = true; }
33
+ else { args.flags[key] = next; i++; }
34
+ } else {
35
+ args._.push(a);
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+
41
+ function die(msg, code = 1) {
42
+ console.error(`error: ${msg}`);
43
+ process.exit(code);
44
+ }
45
+
46
+ function resolveProfile(flags) {
47
+ const wanted = flags.site || process.env.NIRANZWP_SITE;
48
+ const all = listProfiles();
49
+ if (!all.length) die('no sites connected yet. Run: niranzwp auth login <url>');
50
+
51
+ const name = wanted || (all.length === 1 ? all[0].name : null);
52
+ if (!name) die(`multiple sites connected (${all.map((p) => p.name).join(', ')}). Pass --site <profile>.`);
53
+
54
+ const p = getProfile(name);
55
+ if (!p) die(`profile "${name}" has no stored credential. Run: niranzwp auth login <url> --name ${name}`);
56
+ return p;
57
+ }
58
+
59
+ const out = (flags, value, pretty) => {
60
+ if (flags.json || !pretty) console.log(JSON.stringify(value, null, 2));
61
+ else pretty(value);
62
+ };
63
+
64
+ async function main() {
65
+ const { _: pos, flags } = parseArgs(process.argv.slice(2));
66
+ const [cmd, sub, ...rest] = pos;
67
+
68
+ if (!cmd || flags.help || cmd === 'help') { console.log(USAGE); return; }
69
+
70
+ if (cmd === 'auth' && sub === 'login') {
71
+ const url = rest[0];
72
+ if (!url) die('usage: niranzwp auth login <url>');
73
+ const site = normalizeSite(url);
74
+ const creds = await loginWithAppPassword(site, { open: flags.open !== false });
75
+ const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
76
+ saveProfile(name, creds);
77
+ console.log(`Connected "${name}" -> ${creds.siteUrl} as ${creds.user}`);
78
+ console.log(`Credential stored in ${storageKind()}.`);
79
+ console.log(creds.info.tier2
80
+ ? 'This site exposes the Abilities API. Try: niranzwp discover'
81
+ : 'Tier 1 only (core REST). No abilities provider detected on this site.');
82
+ return;
83
+ }
84
+
85
+ if (cmd === 'auth' && sub === 'status') {
86
+ const all = listProfiles();
87
+ if (!all.length) { console.log('No sites connected.'); return; }
88
+ for (const p of all) {
89
+ const full = getProfile(p.name);
90
+ let who = 'credential missing';
91
+ if (full) {
92
+ try {
93
+ const me = await whoami(full);
94
+ who = `${me.name} (id ${me.id}, ${(me.roles || []).join(',') || 'no role'})`;
95
+ } catch (e) {
96
+ who = `unreachable: ${e.message}`;
97
+ }
98
+ }
99
+ console.log(`${p.name}\n ${p.siteUrl}\n ${who}`);
100
+ }
101
+ console.log(`\nStorage: ${storageKind()}\nConfig: ${configDir()}`);
102
+ return;
103
+ }
104
+
105
+ if (cmd === 'auth' && sub === 'logout') {
106
+ const name = rest[0];
107
+ if (!name) die('usage: niranzwp auth logout <profile>');
108
+ deleteProfile(name);
109
+ console.log(`Removed "${name}" locally.`);
110
+ console.log('Revoke the password itself under Users -> Profile -> Application Passwords in WordPress.');
111
+ return;
112
+ }
113
+
114
+ if (cmd === 'probe') {
115
+ const url = sub;
116
+ if (!url) die('usage: niranzwp probe <url>');
117
+ const info = await probe(normalizeSite(url));
118
+ out(flags, info, (i) => {
119
+ console.log(`${i.name} -- ${i.description || ''}`);
120
+ console.log(` ${i.home}`);
121
+ console.log(` Tier 1 (app passwords): ${i.tier1 ? 'yes' : 'no'}`);
122
+ console.log(` Tier 2 (abilities): ${i.tier2 ? 'yes' : 'no'}`);
123
+ console.log(` MCP endpoint: ${i.mcp ? 'yes' : 'no'}`);
124
+ console.log(` OAuth server: ${i.oauth ? 'yes' : 'no'}`);
125
+ console.log(` namespaces: ${i.namespaces.length}`);
126
+ });
127
+ return;
128
+ }
129
+
130
+ if (cmd === 'discover') {
131
+ const p = resolveProfile(flags);
132
+ const list = await listAbilities(p);
133
+ const items = Array.isArray(list) ? list : list?.abilities || [];
134
+ out(flags, items, (rows) => {
135
+ console.log(`${rows.length} abilities on ${p.siteUrl}`);
136
+ for (const a of rows) console.log(` ${a.name}${a.label ? ` -- ${a.label}` : ''}`);
137
+ });
138
+ return;
139
+ }
140
+
141
+ if (cmd === 'post' && sub === 'list') {
142
+ 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
+ });
149
+ out(flags, data, (rows) => {
150
+ const total = headers.get('x-wp-total');
151
+ console.log(`${rows.length} shown of ${total ?? '?'} (${flags.status || 'publish'})`);
152
+ 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)}`);
154
+ }
155
+ });
156
+ return;
157
+ }
158
+
159
+ if (cmd === 'run') {
160
+ const p = resolveProfile(flags);
161
+ const ability = sub;
162
+ if (!ability) die('usage: niranzwp run <ability> [--input <json>|--file <path>]');
163
+ let input = {};
164
+ if (flags.file) input = JSON.parse(readFileSync(flags.file, 'utf8'));
165
+ else if (typeof flags.input === 'string') input = JSON.parse(flags.input);
166
+ const result = await runAbility(p, ability, input);
167
+ console.log(JSON.stringify(result, null, 2));
168
+ return;
169
+ }
170
+
171
+ console.log(USAGE);
172
+ process.exit(1);
173
+ }
174
+
175
+ main().catch((e) => {
176
+ if (e instanceof WpError) {
177
+ die(`${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`);
178
+ }
179
+ die(e.message);
180
+ });
package/lib/auth.js ADDED
@@ -0,0 +1,94 @@
1
+ import { createServer } from 'node:http';
2
+ import { execFile } from 'node:child_process';
3
+ import { platform } from 'node:os';
4
+ import { probe } from './wp.js';
5
+
6
+ // WordPress core ships an Application Password authorization screen at
7
+ // /wp-admin/authorize-application.php. Given a success_url it redirects back
8
+ // with site_url, user_login and password in the query string. That gives us a
9
+ // browser consent flow on ANY WordPress since 5.6 -- no plugin required.
10
+
11
+ function openBrowser(url) {
12
+ const cmd = platform() === 'darwin' ? 'open'
13
+ : platform() === 'win32' ? 'cmd'
14
+ : 'xdg-open';
15
+ const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
16
+ execFile(cmd, args, () => { /* a failure just means the user opens it themselves */ });
17
+ }
18
+
19
+ function page(title, body) {
20
+ return `<!doctype html><meta charset="utf-8">
21
+ <title>${title}</title>
22
+ <style>
23
+ body{font:16px/1.6 -apple-system,system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;color:#111}
24
+ h1{font-size:1.3rem;margin:0 0 .5rem}
25
+ p{color:#555;margin:0}
26
+ code{background:#f4f4f5;padding:.15em .4em;border-radius:4px}
27
+ </style>
28
+ <h1>${title}</h1><p>${body}</p>`;
29
+ }
30
+
31
+ export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI', open = true, timeoutMs = 300000 } = {}) {
32
+ const info = await probe(siteUrl);
33
+
34
+ if (!info.tier1 || !info.authorizeUrl) {
35
+ throw new Error(
36
+ 'This site does not advertise Application Passwords. It needs WordPress 5.6+ over HTTPS, ' +
37
+ 'or WP_ENVIRONMENT_TYPE set to "local". A security plugin may also be disabling them.'
38
+ );
39
+ }
40
+
41
+ return new Promise((resolve, reject) => {
42
+ const server = createServer((req, res) => {
43
+ const url = new URL(req.url, 'http://127.0.0.1');
44
+ if (!url.pathname.startsWith('/callback')) {
45
+ res.writeHead(404).end();
46
+ return;
47
+ }
48
+
49
+ const user = url.searchParams.get('user_login');
50
+ const password = url.searchParams.get('password');
51
+ const site = url.searchParams.get('site_url') || siteUrl;
52
+
53
+ if (!user || !password) {
54
+ res.writeHead(400, { 'Content-Type': 'text/html' })
55
+ .end(page('Authorization failed', 'WordPress did not return a credential. You can close this tab and try again.'));
56
+ cleanup();
57
+ reject(new Error('Authorization was rejected or returned no credential.'));
58
+ return;
59
+ }
60
+
61
+ res.writeHead(200, { 'Content-Type': 'text/html' })
62
+ .end(page('Connected', `<code>niranzwp</code> is now authorized for <code>${site}</code>. You can close this tab.`));
63
+
64
+ cleanup();
65
+ resolve({ siteUrl: site.replace(/\/+$/, ''), user, password, info });
66
+ });
67
+
68
+ const timer = setTimeout(() => {
69
+ cleanup();
70
+ reject(new Error('Timed out waiting for authorization.'));
71
+ }, timeoutMs);
72
+
73
+ function cleanup() {
74
+ clearTimeout(timer);
75
+ server.close();
76
+ }
77
+
78
+ server.on('error', (e) => { cleanup(); reject(e); });
79
+
80
+ // Port 0 lets the OS pick a free port.
81
+ server.listen(0, '127.0.0.1', () => {
82
+ const port = server.address().port;
83
+ const successUrl = `http://127.0.0.1:${port}/callback`;
84
+ const authorize = new URL(info.authorizeUrl);
85
+ authorize.searchParams.set('app_name', appName);
86
+ authorize.searchParams.set('success_url', successUrl);
87
+
88
+ console.error('Approve this connection in your browser:');
89
+ console.error(` ${authorize}`);
90
+ console.error('');
91
+ if (open) openBrowser(authorize.toString());
92
+ });
93
+ });
94
+ }
package/lib/store.js ADDED
@@ -0,0 +1,122 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // Profiles (which sites exist, who we are on each) live in a plain JSON file.
7
+ // Passwords never do -- they go to the OS keychain where available.
8
+
9
+ const DIR = join(homedir(), '.config', 'niranzwp');
10
+ const PROFILES = join(DIR, 'profiles.json');
11
+ const SERVICE = 'niranzwp';
12
+
13
+ function readProfiles() {
14
+ if (!existsSync(PROFILES)) return {};
15
+ try {
16
+ return JSON.parse(readFileSync(PROFILES, 'utf8'));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function writeProfiles(all) {
23
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
24
+ writeFileSync(PROFILES, JSON.stringify(all, null, 2), { mode: 0o600 });
25
+ }
26
+
27
+ const useKeychain = () => platform() === 'darwin';
28
+
29
+ function keychainSet(account, secret) {
30
+ execFileSync('security', [
31
+ 'add-generic-password',
32
+ '-a', account,
33
+ '-s', SERVICE,
34
+ '-w', secret,
35
+ '-U', // update if it already exists
36
+ ]);
37
+ }
38
+
39
+ function keychainGet(account) {
40
+ try {
41
+ return execFileSync('security', [
42
+ 'find-generic-password', '-a', account, '-s', SERVICE, '-w',
43
+ ], { encoding: 'utf8' }).trim();
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function keychainDelete(account) {
50
+ try {
51
+ execFileSync('security', [
52
+ 'delete-generic-password', '-a', account, '-s', SERVICE,
53
+ ], { stdio: 'ignore' });
54
+ } catch { /* nothing stored */ }
55
+ }
56
+
57
+ // Fallback for non-macOS: a 0600 file. Less safe than a keychain, so we say so.
58
+ const secretsPath = () => join(DIR, 'secrets.json');
59
+
60
+ function fileSecrets() {
61
+ if (!existsSync(secretsPath())) return {};
62
+ try {
63
+ return JSON.parse(readFileSync(secretsPath(), 'utf8'));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ function account(name) {
70
+ return `${SERVICE}:${name}`;
71
+ }
72
+
73
+ export function saveProfile(name, { siteUrl, user, password }) {
74
+ const all = readProfiles();
75
+ all[name] = { siteUrl, user, createdAt: new Date().toISOString() };
76
+ writeProfiles(all);
77
+
78
+ if (useKeychain()) {
79
+ keychainSet(account(name), password);
80
+ } else {
81
+ const s = fileSecrets();
82
+ s[name] = password;
83
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
84
+ writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
85
+ }
86
+ }
87
+
88
+ export function getProfile(name) {
89
+ const p = readProfiles()[name];
90
+ if (!p) return null;
91
+ const password = useKeychain() ? keychainGet(account(name)) : fileSecrets()[name];
92
+ if (!password) return null;
93
+ return { name, ...p, password };
94
+ }
95
+
96
+ export function listProfiles() {
97
+ return Object.entries(readProfiles()).map(([name, p]) => ({ name, ...p }));
98
+ }
99
+
100
+ export function deleteProfile(name) {
101
+ const all = readProfiles();
102
+ delete all[name];
103
+ writeProfiles(all);
104
+
105
+ if (useKeychain()) {
106
+ keychainDelete(account(name));
107
+ } else {
108
+ const s = fileSecrets();
109
+ delete s[name];
110
+ if (existsSync(secretsPath())) writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
111
+ }
112
+ }
113
+
114
+ export function storageKind() {
115
+ return useKeychain() ? 'macOS Keychain' : `file (${secretsPath()}, mode 0600)`;
116
+ }
117
+
118
+ export function configDir() {
119
+ return DIR;
120
+ }
121
+
122
+ export { rmSync };
package/lib/wp.js ADDED
@@ -0,0 +1,149 @@
1
+ // Thin WordPress REST client. No dependencies -- Node's fetch is enough.
2
+
3
+ export class WpError extends Error {
4
+ constructor(message, { status, code, body } = {}) {
5
+ super(message);
6
+ this.name = 'WpError';
7
+ this.status = status;
8
+ this.code = code;
9
+ this.body = body;
10
+ }
11
+ }
12
+
13
+ export function normalizeSite(url) {
14
+ let u = url.trim();
15
+ if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
16
+ return u.replace(/\/+$/, '');
17
+ }
18
+
19
+ function authHeader(profile) {
20
+ // Application Passwords are sent as HTTP Basic. WordPress strips the
21
+ // spaces WordPress itself put in the generated password.
22
+ const raw = `${profile.user}:${profile.password.replace(/\s+/g, '')}`;
23
+ return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
24
+ }
25
+
26
+ async function parse(res) {
27
+ const text = await res.text();
28
+ if (!text) return null;
29
+ try {
30
+ return JSON.parse(text);
31
+ } catch {
32
+ return text;
33
+ }
34
+ }
35
+
36
+ export async function request(profile, path, { method = 'GET', body, query, raw = false } = {}) {
37
+ const url = new URL(path.startsWith('http') ? path : `${profile.siteUrl}/wp-json${path}`);
38
+ for (const [k, v] of Object.entries(query || {})) {
39
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
40
+ }
41
+
42
+ const headers = { Accept: 'application/json', 'User-Agent': 'niranzwp' };
43
+ if (profile.password) headers.Authorization = authHeader(profile);
44
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
45
+
46
+ const res = await fetch(url, {
47
+ method,
48
+ headers,
49
+ body: body === undefined ? undefined : JSON.stringify(body),
50
+ });
51
+
52
+ const data = await parse(res);
53
+
54
+ if (!res.ok) {
55
+ const code = data && typeof data === 'object' ? data.code : undefined;
56
+ const msg = (data && typeof data === 'object' && data.message) || `HTTP ${res.status}`;
57
+ throw new WpError(msg, { status: res.status, code, body: data });
58
+ }
59
+
60
+ return raw ? { data, headers: res.headers } : data;
61
+ }
62
+
63
+ // Unauthenticated probe: what does this site actually support?
64
+ export async function probe(siteUrl) {
65
+ let res;
66
+ try {
67
+ res = await fetch(`${siteUrl}/wp-json/`, {
68
+ headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
69
+ });
70
+ } catch (e) {
71
+ throw new WpError(`cannot reach ${siteUrl}: ${e.message}`);
72
+ }
73
+
74
+ const text = await res.text();
75
+ let root;
76
+ try {
77
+ root = JSON.parse(text);
78
+ } catch {
79
+ // A WordPress REST root always returns JSON. HTML means this is either
80
+ // not WordPress, or the REST API is disabled or behind a firewall.
81
+ throw new WpError(
82
+ `${siteUrl} did not return a WordPress REST API (HTTP ${res.status}, got ${
83
+ text.trimStart().startsWith('<') ? 'HTML' : 'non-JSON'
84
+ }). Either it is not WordPress, or /wp-json/ is disabled or blocked.`,
85
+ { status: res.status }
86
+ );
87
+ }
88
+
89
+ if (!root || typeof root !== 'object' || !Array.isArray(root.namespaces)) {
90
+ throw new WpError(`${siteUrl} returned JSON, but not a WordPress REST root.`, { status: res.status });
91
+ }
92
+
93
+ const namespaces = root.namespaces || [];
94
+ const appPasswords = Boolean(root.authentication?.['application-passwords']);
95
+
96
+ // Abilities API is WordPress core from 6.9. Its presence is the Tier 2 signal.
97
+ const abilities = namespaces.includes('wp-abilities/v1');
98
+ const mcp = namespaces.includes('mcp');
99
+
100
+ let oauth = null;
101
+ try {
102
+ const r = await fetch(`${siteUrl}/.well-known/oauth-authorization-server`, {
103
+ headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
104
+ });
105
+ if (r.ok) oauth = await r.json();
106
+ } catch { /* not every site has one */ }
107
+
108
+ return {
109
+ name: root.name,
110
+ description: root.description,
111
+ home: root.home,
112
+ namespaces,
113
+ tier1: appPasswords,
114
+ tier2: abilities,
115
+ mcp,
116
+ oauth: Boolean(oauth),
117
+ oauthMeta: oauth,
118
+ authorizeUrl: root.authentication?.['application-passwords']?.endpoints?.authorization || null,
119
+ };
120
+ }
121
+
122
+ export async function whoami(profile) {
123
+ return request(profile, '/wp/v2/users/me', { query: { context: 'edit' } });
124
+ }
125
+
126
+ export async function listPosts(profile, { status = 'publish', perPage = 10, page = 1, search } = {}) {
127
+ return request(profile, '/wp/v2/posts', {
128
+ raw: true,
129
+ query: {
130
+ status,
131
+ per_page: perPage,
132
+ page,
133
+ search,
134
+ context: 'edit',
135
+ _fields: 'id,date,status,link,title',
136
+ },
137
+ });
138
+ }
139
+
140
+ export async function listAbilities(profile) {
141
+ return request(profile, '/wp-abilities/v1/abilities');
142
+ }
143
+
144
+ export async function runAbility(profile, name, input) {
145
+ return request(profile, `/wp-abilities/v1/abilities/${name}/run`, {
146
+ method: 'POST',
147
+ body: input ?? {},
148
+ });
149
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "niranzwp",
3
+ "version": "0.1.0",
4
+ "description": "A CLI for WordPress. Works on any site via Application Passwords, and unlocks Abilities where a site provides them.",
5
+ "type": "module",
6
+ "bin": {
7
+ "niranzwp": "bin/niranzwp.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "lib",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "license": "MIT"
19
+ }