@wickedevolutions/abilities-mcp 1.3.1 → 1.5.3

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +265 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +328 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. package/package.json +8 -2
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI error helpers + exit-code table.
5
+ *
6
+ * The design doc does not pin a CLI exit-code table — only the constraint that
7
+ * `lib/auth/` itself contains no `process.exit` calls. The table below is a
8
+ * Phase 5 design choice, intentionally narrow:
9
+ *
10
+ * 0 success
11
+ * 1 generic / unexpected error (catch-all, includes --debug stack)
12
+ * 2 usage error (bad args, unknown subcommand)
13
+ * 3 config error (wp-sites.json missing/invalid)
14
+ * 4 auth failure (consent denied, refresh expired,
15
+ * token endpoint 4xx, network)
16
+ * 5 capability-pinning violation (H.2.3 — pinned site lost OAuth)
17
+ *
18
+ * Operator-facing error messages MUST name the next action to take. CliError
19
+ * carries that next-action text on the `nextAction` field so the formatter can
20
+ * render it consistently.
21
+ *
22
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
23
+ * @license GPL-2.0-or-later
24
+ */
25
+
26
+ const EXIT_OK = 0;
27
+ const EXIT_GENERIC = 1;
28
+ const EXIT_USAGE = 2;
29
+ const EXIT_CONFIG = 3;
30
+ const EXIT_AUTH = 4;
31
+ const EXIT_PIN_VIOLATION = 5;
32
+
33
+ class CliError extends Error {
34
+ /**
35
+ * @param {string} message One-line operator-facing summary.
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.exitCode] Defaults to EXIT_GENERIC.
38
+ * @param {string} [opts.nextAction] Required for non-zero exits — names the
39
+ * exact command / action the operator
40
+ * should run next.
41
+ * @param {Error} [opts.cause]
42
+ */
43
+ constructor(message, opts = {}) {
44
+ super(message);
45
+ this.name = 'CliError';
46
+ this.exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : EXIT_GENERIC;
47
+ this.nextAction = opts.nextAction || null;
48
+ // Progress lines accumulated by the command before it threw — these are
49
+ // the operator's record of "how far the command got." The router prints
50
+ // them on stdout so the operator sees the partial trace alongside the
51
+ // error on stderr. (The lib/auth/ state machine has no notion of CLI
52
+ // output, so commands accumulate lines locally and pass them along.)
53
+ if (Array.isArray(opts.progressLines)) this.progressLines = opts.progressLines;
54
+ if (opts.cause) this.cause = opts.cause;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Map a thrown error from `lib/auth/` (or anywhere) onto a CliError.
60
+ * Pure — does not print. The caller decides how to render.
61
+ *
62
+ * @param {Error} err
63
+ * @param {object} [ctx] Optional context to enrich next-action
64
+ * @param {string} [ctx.siteId]
65
+ * @returns {CliError}
66
+ */
67
+ function fromAuthError(err, ctx = {}) {
68
+ if (err instanceof CliError) {
69
+ // Caller may layer additional progressLines onto an already-CliError.
70
+ if (Array.isArray(ctx.progressLines) && !err.progressLines) {
71
+ err.progressLines = ctx.progressLines;
72
+ }
73
+ return err;
74
+ }
75
+
76
+ const code = err && err.code;
77
+ const siteRef = ctx.siteId || (err && err.reauthHint && err.reauthHint.siteId) || null;
78
+ const progressLines = Array.isArray(ctx.progressLines) ? ctx.progressLines : undefined;
79
+
80
+ // H.2.3 — capability pinning failure has its own exit code so scripts can
81
+ // distinguish a possible network-attack signal from a routine auth failure.
82
+ if (err && err.name === 'CapabilityPinningError') {
83
+ return new CliError(err.message, {
84
+ exitCode: EXIT_PIN_VIOLATION,
85
+ nextAction: siteRef
86
+ ? `Run: abilities-mcp force-downgrade ${siteRef} --i-understand-the-risk (only if you intend to override OAuth pinning)`
87
+ : 'Run: abilities-mcp force-downgrade <site_id> --i-understand-the-risk (only if you intend to override OAuth pinning)',
88
+ cause: err,
89
+ });
90
+ }
91
+
92
+ // Operator denied consent on the adapter screen — no remediation needed
93
+ // beyond re-running add-site / reauth.
94
+ if (err && err.name === 'UserDeniedError') {
95
+ return new CliError('Authorization was denied on the consent screen.', {
96
+ exitCode: EXIT_AUTH,
97
+ nextAction: siteRef
98
+ ? `Run: abilities-mcp reauth ${siteRef} (and click Allow on the consent screen)`
99
+ : 'Re-run add-site and click Allow on the consent screen',
100
+ cause: err,
101
+ });
102
+ }
103
+
104
+ // Refresh token rejected by adapter → reauth.
105
+ if (err && err.name === 'RefreshError') {
106
+ if (code === 'reauth_required' || code === 'invalid_grant' || code === 'unauthorized_client') {
107
+ return new CliError(`Refresh token rejected (${code || 'invalid_grant'}).`, {
108
+ exitCode: EXIT_AUTH,
109
+ nextAction: siteRef
110
+ ? `Run: abilities-mcp reauth ${siteRef} to refresh consent`
111
+ : 'Run: abilities-mcp reauth <site_id> to refresh consent',
112
+ cause: err,
113
+ });
114
+ }
115
+ if (code === 'no_refresh_token' || code === 'revoked') {
116
+ return new CliError(err.message || 'No usable refresh token.', {
117
+ exitCode: EXIT_AUTH,
118
+ nextAction: siteRef
119
+ ? `Run: abilities-mcp reauth ${siteRef}`
120
+ : 'Run: abilities-mcp reauth <site_id>',
121
+ cause: err,
122
+ });
123
+ }
124
+ return new CliError(err.message || 'Token refresh failed.', {
125
+ exitCode: EXIT_AUTH,
126
+ nextAction: siteRef
127
+ ? `Run: abilities-mcp reauth ${siteRef} if the failure persists`
128
+ : 'Re-check site connectivity and run reauth if the failure persists',
129
+ cause: err,
130
+ });
131
+ }
132
+
133
+ // Discovery 404 / no metadata → adapter likely not installed.
134
+ if (err && err.name === 'DiscoveryError') {
135
+ return new CliError(err.message || 'OAuth discovery failed.', {
136
+ exitCode: EXIT_AUTH,
137
+ nextAction: 'Verify the site has abilities-mcp-adapter v1.5.0+ installed and reachable over HTTPS',
138
+ cause: err,
139
+ });
140
+ }
141
+
142
+ // Adapter rejected DCR — usually a configuration mismatch on the adapter.
143
+ if (err && err.name === 'RegistrationError') {
144
+ return new CliError(err.message || 'Dynamic Client Registration failed.', {
145
+ exitCode: EXIT_AUTH,
146
+ nextAction: 'Check the adapter\'s OAuth client policy and try add-site again',
147
+ cause: err,
148
+ });
149
+ }
150
+
151
+ // Token exchange rejected — typically PKCE / code re-use.
152
+ if (err && err.name === 'TokenExchangeError') {
153
+ return new CliError(err.message || 'Token exchange failed.', {
154
+ exitCode: EXIT_AUTH,
155
+ nextAction: siteRef
156
+ ? `Run: abilities-mcp reauth ${siteRef}`
157
+ : 'Re-run add-site to start a fresh authorization',
158
+ cause: err,
159
+ });
160
+ }
161
+
162
+ // SecretStore (keytar) unavailable on this host.
163
+ if (err && err.name === 'SecretStoreError') {
164
+ return new CliError(err.message || 'OS keychain unavailable.', {
165
+ exitCode: EXIT_CONFIG,
166
+ nextAction: 'Install OS keychain support (libsecret on Linux) or run on a host with native keychain',
167
+ cause: err,
168
+ });
169
+ }
170
+
171
+ // State machine state-token mismatch — almost always the operator
172
+ // re-clicking an old consent link.
173
+ if (err && err.name === 'StateMismatchError') {
174
+ return new CliError(err.message, {
175
+ exitCode: EXIT_AUTH,
176
+ nextAction: siteRef
177
+ ? `Run: abilities-mcp reauth ${siteRef} and complete the flow without re-using stale links`
178
+ : 'Re-run add-site and complete the flow without re-using stale browser tabs',
179
+ cause: err,
180
+ });
181
+ }
182
+
183
+ // Migration failure — surfaced from config-migration.js.
184
+ if (err && err.name === 'MigrationError') {
185
+ return new CliError(err.message || 'Config migration failed.', {
186
+ exitCode: EXIT_CONFIG,
187
+ nextAction: 'Inspect wp-sites.json (and its .v1.bak) and fix the source schema',
188
+ cause: err,
189
+ });
190
+ }
191
+
192
+ // Fallback — unknown error class.
193
+ const fallback = new CliError(err && err.message ? err.message : String(err), {
194
+ exitCode: EXIT_GENERIC,
195
+ cause: err,
196
+ });
197
+ if (progressLines) fallback.progressLines = progressLines;
198
+ return fallback;
199
+ }
200
+
201
+ /**
202
+ * After fromAuthError() returns, attach `progressLines` collected by the
203
+ * command. Done via a wrapper so each `new CliError(...)` branch above stays
204
+ * a one-liner.
205
+ *
206
+ * @param {CliError} cliErr
207
+ * @param {string[]} progressLines
208
+ * @returns {CliError}
209
+ */
210
+ function withProgress(cliErr, progressLines) {
211
+ if (cliErr instanceof CliError && Array.isArray(progressLines) && progressLines.length) {
212
+ cliErr.progressLines = progressLines;
213
+ }
214
+ return cliErr;
215
+ }
216
+
217
+ module.exports = {
218
+ CliError,
219
+ fromAuthError,
220
+ withProgress,
221
+ EXIT_OK,
222
+ EXIT_GENERIC,
223
+ EXIT_USAGE,
224
+ EXIT_CONFIG,
225
+ EXIT_AUTH,
226
+ EXIT_PIN_VIOLATION,
227
+ };
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ const { parse } = require('./parse-args');
4
+ const { createContext } = require('./context');
5
+ const { CliError, fromAuthError, EXIT_USAGE, EXIT_OK, EXIT_GENERIC } = require('./errors');
6
+ const { renderNextAction } = require('./output');
7
+ const { migrateFile } = require('../auth/config-migration');
8
+
9
+ const COMMANDS = {
10
+ 'add-site': () => require('./commands/add-site'),
11
+ 'reauth': () => require('./commands/reauth'),
12
+ 'revoke': () => require('./commands/revoke'),
13
+ 'list-sites': () => require('./commands/list-sites'),
14
+ 'test': () => require('./commands/test'),
15
+ 'upgrade-auth': () => require('./commands/upgrade-auth'),
16
+ // Documented in Appendix J of the design doc:
17
+ 'force-downgrade': () => require('./commands/force-downgrade'), // J.1 — H.2.3 escape hatch
18
+ 'self-check': () => require('./commands/self-check'), // J.2 — H.2.6 header probe
19
+ };
20
+
21
+ /**
22
+ * Subcommand router for `abilities-mcp <subcommand> ...`.
23
+ *
24
+ * The router is invoked by abilities-mcp.js when the first argv token is one
25
+ * of the known subcommand names. When it isn't, the bridge falls through to
26
+ * MCP server mode (the original behavior).
27
+ *
28
+ * Tests can call `runCommand({ subcommand, argv, ctx })` directly with a
29
+ * test context, bypassing process.argv / process.exit / stdout entirely.
30
+ *
31
+ * Exit codes are defined in `./errors.js` (0/1/2/3/4/5).
32
+ *
33
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
34
+ * @license GPL-2.0-or-later
35
+ */
36
+
37
+ function isKnownSubcommand(name) {
38
+ return Object.prototype.hasOwnProperty.call(COMMANDS, name);
39
+ }
40
+
41
+ /**
42
+ * Run a single subcommand. Pure of process / stdout — returns { exitCode,
43
+ * lines, errLines }. The caller writes to streams and exits.
44
+ *
45
+ * @param {object} opts
46
+ * @param {string} opts.subcommand
47
+ * @param {string[]} opts.argv Tokens after the subcommand name.
48
+ * @param {object} [opts.ctx] Pre-built context (tests). When
49
+ * omitted, createContext(args) is used.
50
+ * @returns {Promise<{exitCode:number, lines:string[], errLines:string[]}>}
51
+ */
52
+ async function runCommand(opts) {
53
+ const args = parse(opts.argv || []);
54
+ if (!isKnownSubcommand(opts.subcommand)) {
55
+ return {
56
+ exitCode: EXIT_USAGE,
57
+ lines: [],
58
+ errLines: [
59
+ `abilities-mcp: unknown subcommand "${opts.subcommand}"`,
60
+ ` → Run: abilities-mcp --help`,
61
+ ],
62
+ };
63
+ }
64
+ const cmd = COMMANDS[opts.subcommand]();
65
+ const ctx = opts.ctx || createContext(args);
66
+
67
+ // Schema v1→v2 migration runs before any subcommand reads the config, so
68
+ // `upgrade-auth`, `add-site`, `list-sites`, etc. work on a fresh v1.4.x
69
+ // upgrade without first starting the bridge as MCP server. `migrateFile`
70
+ // is idempotent (a v2 file is a no-op) and ENOENT-safe (returns
71
+ // missing:true for clean installs that haven't run add-site yet). The
72
+ // migration uses `ctx.secretStore` so lifted secrets land in the same
73
+ // store the subcommand will read from a moment later — production
74
+ // contexts share KeychainSecretStore (entry identity is (service,
75
+ // account), not instance-bound); tests share their MemorySecretStore.
76
+ const preLines = [];
77
+ try {
78
+ if (ctx.configPath) {
79
+ const result = await migrateFile({
80
+ filePath: ctx.configPath,
81
+ secretStore: ctx.secretStore,
82
+ });
83
+ if (result.migrated) {
84
+ preLines.push(
85
+ `Migrated wp-sites.json v1 → v2 (${result.liftedCount} secret(s) lifted; backup: ${result.backupPath})`
86
+ );
87
+ }
88
+ }
89
+ } catch (err) {
90
+ const cliErr = fromAuthError(err);
91
+ return {
92
+ exitCode: cliErr.exitCode || EXIT_GENERIC,
93
+ lines: [],
94
+ errLines: renderNextAction(cliErr),
95
+ };
96
+ }
97
+
98
+ try {
99
+ const r = await cmd.run(args, ctx);
100
+ const lines = preLines.length
101
+ ? preLines.concat(r.lines || [])
102
+ : (r.lines || []);
103
+ return { exitCode: r.exitCode || EXIT_OK, lines, errLines: [] };
104
+ } catch (err) {
105
+ const cliErr = err instanceof CliError ? err : fromAuthError(err);
106
+ const errLines = renderNextAction(cliErr);
107
+ if (ctx.debug && cliErr.cause && cliErr.cause.stack) {
108
+ errLines.push('');
109
+ errLines.push('--- debug stack ---');
110
+ errLines.push(cliErr.cause.stack);
111
+ }
112
+ // If the command accumulated progress before throwing, surface it on
113
+ // stdout — the operator sees how far the command got alongside the
114
+ // stderr error message. Migration-success preLines also need to surface
115
+ // when the subsequent subcommand throws.
116
+ const progress = Array.isArray(cliErr.progressLines) ? cliErr.progressLines : [];
117
+ const lines = preLines.length ? preLines.concat(progress) : progress;
118
+ return {
119
+ exitCode: cliErr.exitCode || EXIT_GENERIC,
120
+ lines,
121
+ errLines,
122
+ };
123
+ }
124
+ }
125
+
126
+ const HELP_TEXT = [
127
+ 'abilities-mcp — MCP bridge for WordPress Abilities API',
128
+ '',
129
+ 'Subcommands:',
130
+ ' add-site <url> Register a new site (OAuth by default)',
131
+ ' --apppassword Use App Password authentication instead',
132
+ ' --username=<user> --password=<pw> (required with --apppassword)',
133
+ ' --scope="<space-sep scopes>" Override default DCR scope',
134
+ ' --site-id=<id> Override the derived site_id',
135
+ ' --label=<text> Human-readable label',
136
+ ' --force Overwrite an existing site_id',
137
+ ' reauth <site_id> Re-run OAuth flow for an existing site',
138
+ ' revoke <site_id> Revoke OAuth tokens (local + remote)',
139
+ ' list-sites Show configured sites + auth status',
140
+ ' test <site_id> Ping the adapter and report scopes',
141
+ ' upgrade-auth <site_id> Migrate App Password → OAuth (Step 1-3)',
142
+ ' --confirm Step 4: remove App Password fallback',
143
+ ' force-downgrade <site_id> Override OAuth pinning (H.2.3)',
144
+ ' --i-understand-the-risk (required)',
145
+ ' --reason="<text>" Audit message (visible in list-sites for 30 days)',
146
+ ' self-check <site_id> Probe Authorization-header survival (H.2.6)',
147
+ '',
148
+ 'Global flags:',
149
+ ' --config=<path> Use this wp-sites.json (defaults: ./wp-sites.json or',
150
+ ' ~/.abilities-mcp/wp-sites.json)',
151
+ ' --debug Include cause stack on errors',
152
+ ' --allow-insecure Allow plain HTTP (localhost dev only)',
153
+ '',
154
+ 'Exit codes:',
155
+ ' 0 success',
156
+ ' 1 unexpected error',
157
+ ' 2 usage error',
158
+ ' 3 config error (wp-sites.json)',
159
+ ' 4 auth failure (consent denied / token rejected / network)',
160
+ ' 5 capability-pinning violation (H.2.3 — pinned site lost OAuth)',
161
+ ];
162
+
163
+ function isHelpToken(tok) {
164
+ return tok === '-h' || tok === '--help' || tok === 'help';
165
+ }
166
+
167
+ module.exports = {
168
+ isKnownSubcommand,
169
+ runCommand,
170
+ COMMANDS,
171
+ HELP_TEXT,
172
+ isHelpToken,
173
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Output formatter for CLI subcommands.
5
+ *
6
+ * Returns lines (strings) rather than printing directly so commands stay
7
+ * testable without stdout capture. The router writes `lines` to stdout, and
8
+ * any `errLines` to stderr, after the command resolves.
9
+ *
10
+ * Renders:
11
+ * - State-machine progress lines (subscribed from lib/auth/ events).
12
+ * - Site tables for `list-sites`.
13
+ * - "next action" hints for failed commands.
14
+ *
15
+ * No emoji unless the spec verbatim text uses one (✓, ✗, ⚠ appear in the
16
+ * design doc's binding error wording — those we preserve).
17
+ *
18
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
19
+ * @license GPL-2.0-or-later
20
+ */
21
+
22
+ const { STATES } = require('../auth/events');
23
+
24
+ const PROGRESS_LABEL = Object.freeze({
25
+ [STATES.DISCOVERING]: '→ Discovering OAuth metadata',
26
+ [STATES.REGISTERING]: '→ Registering bridge client (DCR)',
27
+ [STATES.AWAITING_CONSENT]: '→ Waiting for consent in browser',
28
+ [STATES.EXCHANGING]: '→ Exchanging authorization code for tokens',
29
+ [STATES.COMPLETE]: '✓ Authorization complete',
30
+ [STATES.FAILED]: '✗ Authorization failed',
31
+ });
32
+
33
+ /**
34
+ * Subscribe to an OAuthClient event emitter and push human-readable progress
35
+ * lines onto `out`. The caller decides when to flush; this only mutates `out`.
36
+ *
37
+ * @param {import('node:events').EventEmitter} client
38
+ * @param {string[]} out
39
+ * @param {object} [opts]
40
+ * @param {boolean} [opts.includeProgress] default true — sub-step lines
41
+ * @param {boolean} [opts.includeAuthorizeUrl] default true — print URL when
42
+ * awaiting_consent so headless
43
+ * operators can paste it.
44
+ */
45
+ function subscribeProgress(client, out, opts = {}) {
46
+ const includeProgress = opts.includeProgress !== false;
47
+ const includeAuthorizeUrl = opts.includeAuthorizeUrl !== false;
48
+ client.on('state', ({ to, data }) => {
49
+ const label = PROGRESS_LABEL[to];
50
+ if (label) out.push(label);
51
+ if (includeAuthorizeUrl && to === STATES.AWAITING_CONSENT && data && data.authorizeUrl) {
52
+ out.push(` If a browser tab does not open, paste this URL:`);
53
+ out.push(` ${data.authorizeUrl}`);
54
+ }
55
+ });
56
+ if (includeProgress) {
57
+ client.on('progress', ({ message, data }) => {
58
+ // Pull a couple of useful fields out for the operator without dumping
59
+ // raw JSON. Keep it terse.
60
+ if (message === 'discovery_succeeded' && data && data.asMetadataUrl) {
61
+ out.push(` Authorization server: ${data.asMetadataUrl}`);
62
+ } else if (message === 'registered' && data && data.clientId) {
63
+ out.push(` Registered client: ${data.clientId}`);
64
+ } else if (message === 'callback_received') {
65
+ out.push(' Consent received from browser');
66
+ } else if (message === 'browser_launch_failed') {
67
+ out.push(' (Browser launch failed — paste the URL above into your browser)');
68
+ } else if (message === 'reusing_persisted_client_id' && data && data.clientId) {
69
+ out.push(` Reusing persisted client_id: ${data.clientId}`);
70
+ }
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Render a list of `sites` per F.5 example:
77
+ *
78
+ * SITE URL AUTH USER SCOPES EXPIRES
79
+ * siteA https://siteA.com oauth wp_agent read, write, menus in 87 days
80
+ *
81
+ * @param {Array<{
82
+ * siteId: string,
83
+ * url: string,
84
+ * authMethod: string,
85
+ * user: string,
86
+ * scopesShort: string,
87
+ * expires: string,
88
+ * statusBadge: string,
89
+ * downgradeAnnotation: string,
90
+ * }>} rows
91
+ * @param {object} [opts]
92
+ * @param {number} [opts.maxWidth] Default 120 — wraps wide tables to fit.
93
+ * @returns {string[]} lines
94
+ */
95
+ function renderSiteTable(rows, opts = {}) {
96
+ if (!rows.length) {
97
+ return ['(no sites configured — run: abilities-mcp add-site <url>)'];
98
+ }
99
+ const headers = ['SITE', 'URL', 'AUTH', 'USER', 'SCOPES', 'EXPIRES', 'STATUS'];
100
+ const widths = headers.map((h) => h.length);
101
+ for (const r of rows) {
102
+ widths[0] = Math.max(widths[0], r.siteId.length);
103
+ widths[1] = Math.max(widths[1], r.url.length);
104
+ widths[2] = Math.max(widths[2], r.authMethod.length);
105
+ widths[3] = Math.max(widths[3], r.user.length);
106
+ widths[4] = Math.max(widths[4], r.scopesShort.length);
107
+ widths[5] = Math.max(widths[5], r.expires.length);
108
+ widths[6] = Math.max(widths[6], r.statusBadge.length);
109
+ }
110
+ const fmt = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd();
111
+ const lines = [];
112
+ lines.push(fmt(headers));
113
+ for (const r of rows) {
114
+ lines.push(fmt([
115
+ r.siteId, r.url, r.authMethod, r.user, r.scopesShort, r.expires, r.statusBadge,
116
+ ]));
117
+ if (r.downgradeAnnotation) {
118
+ lines.push(` ${r.downgradeAnnotation}`);
119
+ }
120
+ }
121
+ return lines;
122
+ }
123
+
124
+ /**
125
+ * Compute a human "in N days" / "expired" string from an ISO timestamp.
126
+ * @param {string|null|undefined} iso
127
+ * @param {number} [nowMs]
128
+ * @returns {string}
129
+ */
130
+ function expiresLabel(iso, nowMs) {
131
+ if (!iso) return '—';
132
+ const at = Date.parse(iso);
133
+ if (Number.isNaN(at)) return '—';
134
+ const now = typeof nowMs === 'number' ? nowMs : Date.now();
135
+ const days = Math.floor((at - now) / (24 * 3600 * 1000));
136
+ if (days < 0) return `expired ${-days}d ago`;
137
+ if (days === 0) return 'today';
138
+ if (days === 1) return 'in 1 day';
139
+ return `in ${days} days`;
140
+ }
141
+
142
+ /**
143
+ * Compact rendering of a scope list for the EXPIRES table.
144
+ * Drops the `abilities:` prefix and joins with commas. Truncates to 4 entries
145
+ * with a "+N" suffix to keep the table readable.
146
+ *
147
+ * @param {string[]} scopes
148
+ * @returns {string}
149
+ */
150
+ function shortScopes(scopes) {
151
+ if (!Array.isArray(scopes) || scopes.length === 0) return '(full)';
152
+ const trimmed = scopes.map((s) => s.replace(/^abilities:/, ''));
153
+ if (trimmed.length <= 4) return trimmed.join(', ');
154
+ return trimmed.slice(0, 3).join(', ') + ` +${trimmed.length - 3}`;
155
+ }
156
+
157
+ /**
158
+ * Render the trailing "next action" hint for a failed CliError.
159
+ * @param {import('./errors').CliError} err
160
+ * @returns {string[]}
161
+ */
162
+ function renderNextAction(err) {
163
+ const lines = [`✗ ${err.message}`];
164
+ if (err.nextAction) lines.push(` → ${err.nextAction}`);
165
+ return lines;
166
+ }
167
+
168
+ module.exports = {
169
+ subscribeProgress,
170
+ renderSiteTable,
171
+ renderNextAction,
172
+ expiresLabel,
173
+ shortScopes,
174
+ PROGRESS_LABEL,
175
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal CLI arg parser for the abilities-mcp subcommand surface.
5
+ *
6
+ * Supported forms:
7
+ * <subcommand> <positional...> [--flag] [--key=value] [--key value]
8
+ *
9
+ * Boolean flags: `--debug`, `--apppassword`, `--confirm`, `--force`,
10
+ * `--allow-insecure`, `--i-understand-the-risk`.
11
+ *
12
+ * Value flags accept either `--key=value` or `--key value` form.
13
+ *
14
+ * Long options only — no short forms — to keep the surface small and
15
+ * predictable. The argv array starts at the subcommand (i.e. caller has
16
+ * already sliced argv to remove `node` / script path).
17
+ *
18
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
19
+ * @license GPL-2.0-or-later
20
+ */
21
+
22
+ // Keys we treat as boolean even when they appear as `--key value` — value
23
+ // will be left as a positional. (Currently empty; we use the "next token
24
+ // is a flag → boolean" heuristic, which works for our flags.)
25
+ const BOOLEAN_FLAGS = new Set([
26
+ 'debug',
27
+ 'apppassword',
28
+ 'confirm',
29
+ 'force',
30
+ 'allow-insecure',
31
+ 'i-understand-the-risk',
32
+ ]);
33
+
34
+ /**
35
+ * @param {string[]} argv Tokens after the subcommand name.
36
+ * @returns {object} { _: [...positionals], <flag>: <value|true>, ... }
37
+ */
38
+ function parse(argv) {
39
+ const out = { _: [] };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const tok = argv[i];
42
+ if (tok === '--') {
43
+ // Everything after `--` is positional.
44
+ for (let j = i + 1; j < argv.length; j++) out._.push(argv[j]);
45
+ break;
46
+ }
47
+ if (tok.startsWith('--')) {
48
+ const eq = tok.indexOf('=');
49
+ if (eq > -1) {
50
+ const key = tok.slice(2, eq);
51
+ const value = tok.slice(eq + 1);
52
+ out[key] = _coerce(value);
53
+ continue;
54
+ }
55
+ const key = tok.slice(2);
56
+ if (BOOLEAN_FLAGS.has(key)) {
57
+ out[key] = true;
58
+ continue;
59
+ }
60
+ const next = argv[i + 1];
61
+ if (next === undefined || next.startsWith('--')) {
62
+ out[key] = true;
63
+ continue;
64
+ }
65
+ out[key] = _coerce(next);
66
+ i++;
67
+ } else {
68
+ out._.push(tok);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function _coerce(s) {
75
+ if (s === 'true') return true;
76
+ if (s === 'false') return false;
77
+ return s;
78
+ }
79
+
80
+ module.exports = { parse, BOOLEAN_FLAGS };