@ahrzb/personal-mcp-cli 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.
@@ -0,0 +1,2300 @@
1
+ /**
2
+ * cli/src/main.ts — the pmcp command surface (§10): argv in, exit code out.
3
+ *
4
+ * This module OWNS the CLI's presentation layer: the argv grammar (commander 15 program,
5
+ * the global `--profile`/`--json`/`--no-color`/`--yes` flags, `--args '{…}'` vs
6
+ * `key=value` tool arguments, the `<slug>_<tool>` aggregated-name split, the path-style
7
+ * refs `describe`/`get` take), every table/plan/confirmation rendering and exit-code
8
+ * decision, and the CLI's copies of the pinned wire shapes below. It HIDES the transport:
9
+ * every command except the auth and profile families is presentation sugar over MCP
10
+ * tools/call, so no command is a capability an agent holding the same token lacks (§8's
11
+ * parity invariant — only the UX differs). plan.ts stays pure: this module performs all
12
+ * I/O — file reads, tool calls, prompts — and hands the planner plain data. Grants have no
13
+ * imperative family on purpose: they are managed declaratively via diff/apply, or through
14
+ * `pmcp call pmcp grant_set` like any other tool.
15
+ *
16
+ * Three modules carry what used to live here: config.ts owns the profile store and the
17
+ * precedence, render.ts owns column/schema/JSON rendering, errors.ts owns the frozen error
18
+ * grammar. This file is the composition — argv, network, and which of the two renderings
19
+ * (human or `--json`) each command emits.
20
+ *
21
+ * ponytail: the "official MCP client" is not installed, so the two seams below speak the
22
+ * hub's stateless POST endpoint with `fetch` — one JSON-RPC message per request, exactly
23
+ * what §7 serves. Swap them for the SDK client the day it is a dependency; nothing above
24
+ * them knows the difference.
25
+ */
26
+
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { Command, CommanderError } from "commander";
29
+ import { parse as parseYaml } from "yaml";
30
+ // The extension is spelled out so `node --experimental-strip-types cli/src/main.ts` can
31
+ // resolve it — Node's own type stripping resolves a relative import only WITH one.
32
+ import {
33
+ activeProfile,
34
+ configPath,
35
+ profileOf,
36
+ readConfig,
37
+ resolveActiveProfile,
38
+ writeConfig,
39
+ } from "./config.mjs";
40
+ import { CliError, didYouMean, emitError } from "./errors.mjs";
41
+ import { parseDesired, planChanges } from "./plan.mjs";
42
+
43
+ import { catalogLine, columnize, renderJson, schemaTable, styling, wrapText } from "./render.mjs";
44
+
45
+ /** Printed by `--version`; kept in step with cli/package.json by hand (dist has no reader for it). */
46
+ const VERSION = "0.1.0";
47
+
48
+ /**
49
+ * COPIED wire shape — the GET /api/whoami response, pinned by §8 as the
50
+ * CLI↔server contract. Deliberately duplicated here rather than shared through
51
+ * a package; tests pin both sides. `principal` is `"user:<name>"` or
52
+ * `"sa:<slug>"`; `namespace` is the owner username every `/<user>/mcp…` URL is
53
+ * built from — the CLI never guesses it.
54
+ */
55
+
56
+
57
+ /**
58
+ * COPIED wire shape — the `data` of a -32003 "approval required" error (§7).
59
+ * Deliberately duplicated (no shared package; tests pin both sides).
60
+ * `approvalUrl` is absolute and ready to print; `expiresAt` is an ISO-8601
61
+ * instant bounding both the pending wait and the post-approval retry window.
62
+ */
63
+
64
+
65
+
66
+
67
+
68
+
69
+ /**
70
+ * COPIED wire shape — one `service_list` / `service_get` row (§8's pinned cross-front
71
+ * shape, the server's own `ServiceRow`; contracts/service-list.json is the lock).
72
+ * Deliberately duplicated (no shared package) and deliberately FLAT where the server's is
73
+ * a discriminated union: the CLI branches on `kind` at runtime, so the per-kind fields are
74
+ * optional here rather than three types. Declared once so `ls`, `account`, and the diff
75
+ * planner's read share one decoding instead of three private ones — a field renamed
76
+ * server-side then fails to compile here rather than emptying a column.
77
+ */
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+    
97
+
98
+
99
+
100
+
101
+
102
+ /**
103
+ * COPIED wire shape — one `account_list` row, grants inline as the flat
104
+ * `role[:approval]` strings `grant_set` takes (§8 pins that there is no separate
105
+ * grant-read tool; contracts/account-list.json is the lock).
106
+ */
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+ /**
115
+ * COPIED wire vocabulary — the hub's JSON-RPC error codes (§7). Deliberately
116
+ * duplicated (no shared package; tests pin both sides). The CLI renders these
117
+ * — -32003 gets its ApprovalRequiredData surfaced as instructions — and treats
118
+ * any other code as a plain failure.
119
+ */
120
+ export const HUB_ERRORS = {
121
+ serviceUnavailable: -32000,
122
+ toolNotPermitted: -32001,
123
+ serviceArchived: -32002,
124
+ approvalRequired: -32003,
125
+ invalidParams: -32602,
126
+ methodNotFound: -32601,
127
+ } ;
128
+
129
+ /**
130
+ * A JSON-RPC error reply as a thrown value — the CLI-local mirror of the hub's
131
+ * error vocabulary (HUB_ERRORS). `data` is the wire `error.data` verbatim; for
132
+ * code -32003 it is ApprovalRequiredData. Thrown by the MCP seams below,
133
+ * rendered only by `call` and main's last-resort handler.
134
+ */
135
+ export class HubRpcError extends Error {
136
+ code ;
137
+ data ;
138
+ constructor(code , message , data ) {
139
+ // deps: none
140
+ super(message);
141
+ this.code = code;
142
+ this.data = data;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Everything a resolved command needs to reach the hub: the https origin, the
148
+ * bearer token (session or `pmcp_sa_` — never `pmcp_svc_`, §10), and the
149
+ * whoami-resolved identity. `namespace` is the sole source of `/<user>/mcp…`
150
+ * URLs. Built once per invocation by resolveContext; commands never read
151
+ * config or environment themselves.
152
+ */
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+ // ── the invocation-wide switches (§10's output contract) ────────────────────────────────
161
+
162
+ /**
163
+ * The flags that apply to every command, extracted from argv BEFORE commander sees it.
164
+ * They are global in position as well as in meaning — `pmcp service --yes delete news` and
165
+ * `pmcp service delete news --yes` are the same command — which commander's per-command
166
+ * option model cannot express without redeclaring four options on thirty subcommands.
167
+ *
168
+ * `color` is the `--no-color` flag alone; the TTY and NO_COLOR halves of §10's gate are
169
+ * folded in by `resetOutput` below, so every renderer downstream reads one boolean.
170
+ */
171
+
172
+
173
+ let globals = { json: false, color: true, yes: false, words: [] };
174
+
175
+ /** Set by a command that printed its result and still must not exit 0 (an `isError` tool result). */
176
+ let pendingExit = 0;
177
+
178
+ /**
179
+ * What commander wrote to its error stream. Buffered rather than printed, because commander
180
+ * puts two different things there: its own `error: …` prose, which emitError re-renders with
181
+ * a code, and the HELP of a family invoked with no subcommand, which §10 wants on stdout.
182
+ */
183
+ let commanderOut = "";
184
+
185
+ function extractGlobals(argv ) {
186
+ const words = [];
187
+ const found = { json: false, color: true, yes: false, words };
188
+ for (let index = 0; index < argv.length; index += 1) {
189
+ const word = argv[index];
190
+ if (word === "--json") found.json = true;
191
+ else if (word === "--no-color") found.color = false;
192
+ else if (word === "--yes") found.yes = true;
193
+ else if (word === "--profile") found.profile = argv[(index += 1)];
194
+ else if (word.startsWith("--profile=")) found.profile = word.slice("--profile=".length);
195
+ else words.push(word);
196
+ }
197
+ return found;
198
+ }
199
+
200
+ /** True when human output may carry color and `…` truncation: a TTY, unhindered (§10). */
201
+ function decorated() {
202
+ return globals.color && (process.env.NO_COLOR ?? "") === "" && process.stdout.isTTY === true;
203
+ }
204
+
205
+ function write(text ) {
206
+ process.stdout.write(text);
207
+ }
208
+
209
+ /**
210
+ * The color gate for JSON stdout. `--json` is a MACHINE stream (§10: one JSON document on
211
+ * stdout, nothing else), and an agent harness commonly allocates a pty — so the TTY half of
212
+ * `decorated()` must not apply here, or the exact consumer this redesign is built for gets
213
+ * bytes `JSON.parse` rejects. Human result rendering keeps its color.
214
+ */
215
+ function documentColor() {
216
+ return !globals.json && decorated();
217
+ }
218
+
219
+ /** The single `--json` document a data command emits: stdout, nothing else on stdout (§10). */
220
+ function emitDocument(value ) {
221
+ write(`${renderJson(value, documentColor())}\n`);
222
+ return 0;
223
+ }
224
+
225
+ // ── config, context, transport ──────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Builds the per-invocation context: the ACTIVE profile's stored url/token overlaid
229
+ * by the flat PMCP_URL / PMCP_TOKEN (the environment is profile-free, §10), then one
230
+ * GET /api/whoami to learn principal and namespace (§10 — this is how a service-account
231
+ * key learns whose namespace it lives in). A `pmcp_svc_`-prefixed token is refused here
232
+ * with a clear message — every consumer surface rejects service tokens, so failing early
233
+ * beats a confusing server 401; no token at all fails with a "run pmcp login" hint that
234
+ * names the profile, since a `--profile` typo and an expired session look identical
235
+ * otherwise.
236
+ */
237
+ async function resolveContext(profileName ) {
238
+ // deps: config.readConfig · node:process · fetch GET /api/whoami
239
+ const config = readConfig();
240
+ const name = activeProfile(config, profileName);
241
+ const stored = profileOf(config, name);
242
+ const origin = (process.env.PMCP_URL ?? stored.url ?? "").replace(/\/+$/, "");
243
+ const token = process.env.PMCP_TOKEN ?? stored.token ?? "";
244
+ if (origin === "") {
245
+ throw new CliError("no_url", `no hub url for profile ${name}`, {
246
+ hints: ["pmcp login --url https://…", "or set PMCP_URL"],
247
+ });
248
+ }
249
+ if (token === "") {
250
+ throw new CliError("unauthenticated", `not logged in (profile ${name})`, { hints: [`pmcp login --profile ${name}`] });
251
+ }
252
+ if (token.startsWith("pmcp_svc_")) {
253
+ throw new CliError(
254
+ "unauthenticated",
255
+ "a pmcp_svc_ service token is refused by every consumer surface: use a session or a pmcp_sa_ key",
256
+ );
257
+ }
258
+ const response = await fetch(`${origin}/api/whoami`, { headers: { Authorization: `Bearer ${token}` } });
259
+ if (!response.ok) {
260
+ throw new CliError("unauthenticated", `whoami → ${response.status}: the token is not valid for ${origin}`);
261
+ }
262
+ const me = (await response.json()) ;
263
+ return { origin, token, principal: me.principal, namespace: me.namespace };
264
+ }
265
+
266
+ /** The context every sugar command opens with — one place, so `--profile` is read once. */
267
+ function context() {
268
+ return resolveContext(globals.profile);
269
+ }
270
+
271
+ /** One JSON-RPC request against a scoped MCP endpoint — the hub is POST-only (§7). */
272
+ async function rpc(ctx , path , method , params ) {
273
+ const response = await fetch(`${ctx.origin}${path}`, {
274
+ method: "POST",
275
+ headers: { Authorization: `Bearer ${ctx.token}`, "Content-Type": "application/json" },
276
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }),
277
+ });
278
+ if (!response.ok) throw new Error(`${method} → HTTP ${response.status}`);
279
+ const body = (await response.json()) ;
280
+ // §7 answers 200 whether or not it refused: a JSON-RPC error is the refusal.
281
+ if (body.error !== undefined) throw new HubRpcError(body.error.code, body.error.message, body.error.data);
282
+ return body.result;
283
+ }
284
+
285
+ /** The scoped endpoint a service's own gateway methods are addressed to (§20.2). */
286
+ function scoped(ctx , slug ) {
287
+ return `/${ctx.namespace}/mcp/${slug}`;
288
+ }
289
+
290
+ /**
291
+ * One tools/list against `POST <origin>/<namespace>/mcp/<slug>` — a short-lived stateless
292
+ * session per invocation (the hub is POST-only, §7). Returns the grant-filtered descriptors
293
+ * exactly as the hub sent them; a JSON-RPC error reply is thrown as HubRpcError.
294
+ */
295
+ async function mcpList(ctx , slug ) {
296
+ const result = (await rpc(ctx, scoped(ctx, slug), "tools/list")) ;
297
+ return result?.tools ?? [];
298
+ }
299
+
300
+ /**
301
+ * One tools/call against the scoped endpoint — the whole transport of every
302
+ * non-auth command, admin work included (slug "pmcp" reaches §8's ops table).
303
+ * Returns the result verbatim on success; a JSON-RPC error reply is thrown as
304
+ * HubRpcError so renderers can branch on HUB_ERRORS. Never retries — an
305
+ * approval retry is the caller's explicit, identical-args act (§7).
306
+ */
307
+ async function mcpCall(
308
+ ctx ,
309
+ slug ,
310
+ tool ,
311
+ args ,
312
+ ) {
313
+ return rpc(ctx, scoped(ctx, slug), "tools/call", { name: tool, arguments: args });
314
+ }
315
+
316
+ /** One admin op through the builtin `pmcp` service, unwrapped to its structuredContent (§8). */
317
+ async function adminOp(ctx , name , args = {}) {
318
+ const result = (await mcpCall(ctx, PMCP_SLUG, name, args)) ;
319
+ return result?.structuredContent ?? {};
320
+ }
321
+
322
+ /**
323
+ * The one reader of a list-shaped op result: `rows<ServiceRow>(await adminOp(…),
324
+ * "services")`. The typed row is the point — every caller shares ServiceRow / AccountRow
325
+ * instead of re-deriving a row's shape by hand at each rendering site.
326
+ */
327
+ function rows (result , key ) {
328
+ return (Array.isArray(result[key]) ? result[key] : []) ;
329
+ }
330
+
331
+ /** The reserved slug §8 pins for the hub's own tools. */
332
+ const PMCP_SLUG = "pmcp";
333
+
334
+ /**
335
+ * The diff planner's entire view of the server, read in exactly two calls —
336
+ * service_list plus account_list (§8 pins that grants ride account_list
337
+ * inline; there is no separate grant-read tool) — reshaped into
338
+ * plan.CurrentState. Read-only.
339
+ */
340
+ async function readCurrentState(ctx ) {
341
+ // deps: mcpCall
342
+ const services = rows (await adminOp(ctx, "service_list"), "services");
343
+ const accounts = rows (await adminOp(ctx, "account_list"), "accounts");
344
+ return {
345
+ services: services.map(
346
+ (row) => ({
347
+ slug: row.slug,
348
+ // The builtin row reports `kind: "builtin"`; the planner only ever needs to know
349
+ // that it is not plannable.
350
+ kind: row.kind === "proxy" ? "proxy" : "tunnel",
351
+ name: row.name,
352
+ description: row.description,
353
+ archived: row.archived,
354
+ builtin: row.builtin === true,
355
+ roles: row.roles,
356
+ redact: row.redact,
357
+ redactResults: row.redactResults,
358
+ logBodies: row.logBodies,
359
+ ...(row.kind === "proxy"
360
+ ? {
361
+ endpoint: row.endpoint ?? "",
362
+ auth: row.auth ?? "headers",
363
+ forwardIdentity: row.forwardIdentity === true,
364
+ // Passed through UNDEFAULTED: absent on the row means the service declared
365
+ // nothing, which is a value the planner compares (§20.2's default is applied
366
+ // by plan.canonicalCapabilities, in one place, on both sides at once).
367
+ ...(row.capabilities === undefined ? {} : { capabilities: row.capabilities }),
368
+ }
369
+ : {}),
370
+ }),
371
+ ),
372
+ accounts: accounts.map(
373
+ (row) => ({
374
+ slug: row.slug,
375
+ name: row.name,
376
+ description: row.description,
377
+ // account_list carries grants inline, as the flat `role[:approval]` strings
378
+ // grant_set takes — the planner works in the split shape.
379
+ grants: Object.fromEntries(
380
+ Object.entries(row.grants).map(([service, roles]) => [service, roles.map(splitGrant)]),
381
+ ),
382
+ }),
383
+ ),
384
+ };
385
+ }
386
+
387
+ /** `reader:approval` → approval mode; anything else is an allow grant of that name. */
388
+ function splitGrant(role ) {
389
+ return role.endsWith(":approval")
390
+ ? { role: role.slice(0, -":approval".length), mode: "approval" }
391
+ : { role, mode: "allow" };
392
+ }
393
+
394
+ /**
395
+ * The one human rendering of a Plan, shared by diff and apply so the two can
396
+ * never disagree about what a plan looks like: one summary line per step with
397
+ * destructive steps flagged, then warnings, then hard errors. Pure string
398
+ * building; printing is the caller's.
399
+ */
400
+ function renderPlan(p ) {
401
+ // deps: render.styling
402
+ const c = styling(decorated());
403
+ const lines = p.steps.map((step) =>
404
+ step.destructive ? ` ${c.red("!")} ${step.summary}` : ` ${c.green("+")} ${step.summary}`,
405
+ );
406
+ if (lines.length === 0) lines.push(" (no changes)");
407
+ for (const warning of p.warnings) lines.push(` ${c.yellow(`warning: ${warning}`)}`);
408
+ for (const error of p.errors) lines.push(` ${c.red(`ERROR: ${error}`)}`);
409
+ return lines.join("\n");
410
+ }
411
+
412
+ // ── the auth family: the only commands that are not MCP-tool sugar ─────────────────────
413
+
414
+ /** The RFC 8628 client identifier this CLI presents; better-auth records it on the code. */
415
+ const DEVICE_CLIENT_ID = "pmcp-cli";
416
+ const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
417
+
418
+ /**
419
+ * `pmcp whoami` — the pinned WhoamiResponse from GET /api/whoami, principal AND namespace
420
+ * in both renderings (§10: for a service-account key the two differ, and the namespace is
421
+ * what every `/<user>/mcp…` URL is built from). Logged out is `unauthenticated`, exit 1 —
422
+ * resolveContext raises it before any request.
423
+ */
424
+ export async function whoami() {
425
+ const ctx = await context();
426
+ const profile = resolveActiveProfile(globals.profile).name;
427
+ if (globals.json) {
428
+ return emitDocument({ principal: ctx.principal, namespace: ctx.namespace, url: ctx.origin, profile });
429
+ }
430
+ const c = styling(decorated());
431
+ write(`${c.bold(ctx.principal)} @ ${ctx.origin} (namespace ${ctx.namespace}, profile ${profile})\n`);
432
+ return 0;
433
+ }
434
+
435
+ /** `pmcp logout` — revokes the session server-side, then clears the ACTIVE profile's token alone. */
436
+ export async function logout() {
437
+ const config = readConfig();
438
+ const name = activeProfile(config, globals.profile);
439
+ const stored = profileOf(config, name);
440
+ const origin = process.env.PMCP_URL ?? stored.url;
441
+ const token = process.env.PMCP_TOKEN ?? stored.token;
442
+ if (origin !== undefined && token !== undefined && token !== "") {
443
+ // Best effort: a session the hub already dropped is still gone locally.
444
+ await fetch(`${origin}/api/auth/sign-out`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }).catch(
445
+ () => undefined,
446
+ );
447
+ }
448
+ // The token, and nothing else: the url stays, the secret beside it stays, and the
449
+ // other profiles are still logged in.
450
+ (config.profiles[name] ??= {}).token = "";
451
+ writeConfig(config);
452
+ if (globals.json) return emitDocument({ profile: name, token: false });
453
+ write("logged out\n");
454
+ return 0;
455
+ }
456
+
457
+ /**
458
+ * `pmcp login [--profile <name>] [--url <origin>]` — the RFC 8628 device flow against
459
+ * better-auth's endpoints, and the only writer of a profile's token. On a TTY a missing
460
+ * url is asked for (@clack); with `--json` the device document goes to stdout the moment
461
+ * the hub issues it and the outcome document follows, with every line of chatter on stderr
462
+ * (§10's output contract: one machine-readable stream). Polling stops at the device code's
463
+ * own expiry — `login_timeout`, never an unbounded wait. The write touches ONE profile;
464
+ * the top-level default is set only when this write creates the file.
465
+ */
466
+ export async function login(url ) {
467
+ const config = readConfig();
468
+ const name = activeProfile(config, globals.profile);
469
+ const stored = profileOf(config, name);
470
+ let origin = (url ?? process.env.PMCP_URL ?? stored.url ?? "").replace(/\/+$/, "");
471
+ if (origin === "" && process.stdin.isTTY === true && !globals.json) origin = (await askForUrl()).replace(/\/+$/, "");
472
+ if (origin === "") {
473
+ throw new CliError("no_url", "no hub url", { hints: ["pmcp login --url https://…"] });
474
+ }
475
+
476
+ const requested = await postJson(`${origin}/api/auth/device/code`, { client_id: DEVICE_CLIENT_ID });
477
+ const userCode = String(requested.user_code ?? "");
478
+ const deviceCode = String(requested.device_code ?? "");
479
+ const verification = absolute(origin, String(requested.verification_uri_complete ?? requested.verification_uri ?? `${origin}/device`));
480
+ const expiresIn = Number(requested.expires_in ?? 600);
481
+ if (userCode === "" || deviceCode === "") throw new CliError("remote_error", "the hub issued no device code");
482
+ if (globals.json) {
483
+ write(`${JSON.stringify({ verificationUri: verification, userCode, expiresIn })}\n`);
484
+ process.stderr.write(`waiting for approval at ${verification}\n`);
485
+ } else {
486
+ write(`Visit ${verification} and enter code ${userCode}\n`);
487
+ }
488
+
489
+ // Poll at the interval the hub asked for, honouring slow_down, until the code dies.
490
+ let intervalMs = Number(requested.interval ?? 5) * 1000;
491
+ const deadline = Date.now() + expiresIn * 1000;
492
+ for (;;) {
493
+ if (Date.now() > deadline) {
494
+ throw new CliError("login_timeout", "the device code expired before it was approved", {
495
+ hints: ["pmcp login"],
496
+ });
497
+ }
498
+ await sleep(intervalMs);
499
+ const response = await fetch(`${origin}/api/auth/device/token`, {
500
+ method: "POST",
501
+ headers: { "Content-Type": "application/json" },
502
+ body: JSON.stringify({ grant_type: DEVICE_GRANT_TYPE, device_code: deviceCode, client_id: DEVICE_CLIENT_ID }),
503
+ });
504
+ const body = (await response.json().catch(() => ({}))) ;
505
+ if (response.ok && typeof body.access_token === "string") {
506
+ const profile = (config.profiles[name] ??= {});
507
+ profile.url = origin;
508
+ profile.token = body.access_token;
509
+ // The top-level default is set only when this write CREATES the file (§10): a
510
+ // machine with one profile should not have to name it twice, and a machine with
511
+ // several must never have its default moved by a login it did not ask that of.
512
+ if (config.profile === undefined && !existsSync(configPath())) config.profile = name;
513
+ writeConfig(config);
514
+ const ctx = await resolveContext(name);
515
+ if (globals.json) {
516
+ // Compact, like the device document above it: `login --json` is the one command
517
+ // that writes TWO documents to one stream (mock §3), and a pretty-printed pair
518
+ // could not be read back a line at a time.
519
+ write(`${JSON.stringify({ principal: ctx.principal, namespace: ctx.namespace, profile: name })}\n`);
520
+ return 0;
521
+ }
522
+ write(`Logged in as ${ctx.principal} (profile ${name})\n`);
523
+ return 0;
524
+ }
525
+ if (body.error === "authorization_pending") continue;
526
+ if (body.error === "slow_down") {
527
+ intervalMs += 5_000;
528
+ continue;
529
+ }
530
+ throw new CliError("remote_error", `device authorization failed: ${String(body.error ?? response.status)}`);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * The one interactive prompt outside `profile add` (§10): only on a TTY, only for a piece
536
+ * the invocation genuinely lacks. @clack is imported dynamically so the module graph of a
537
+ * non-interactive run — including the parity suite, which imports this file inside workerd
538
+ * — never loads a terminal library it will not use.
539
+ */
540
+ async function askForUrl() {
541
+ const { isCancel, text } = await import("@clack/prompts");
542
+ const answer = await text({ message: "Hub URL", placeholder: "https://hub.example.com" });
543
+ if (isCancel(answer) || typeof answer !== "string" || answer.trim() === "") {
544
+ throw new CliError("no_url", "no hub url", { hints: ["pmcp login --url https://…"] });
545
+ }
546
+ return answer.trim();
547
+ }
548
+
549
+ async function postJson(url , body ) {
550
+ const response = await fetch(url, {
551
+ method: "POST",
552
+ headers: { "Content-Type": "application/json" },
553
+ body: JSON.stringify(body),
554
+ });
555
+ const parsed = (await response.json().catch(() => ({}))) ;
556
+ if (!response.ok) {
557
+ throw new CliError("remote_error", `${url} → ${response.status} ${String(parsed.error_description ?? parsed.error ?? "")}`);
558
+ }
559
+ return parsed;
560
+ }
561
+
562
+ function absolute(origin , uri ) {
563
+ return uri.startsWith("http") ? uri : `${origin}${uri.startsWith("/") ? "" : "/"}${uri}`;
564
+ }
565
+
566
+ function sleep(ms ) {
567
+ return new Promise((resolve) => setTimeout(resolve, ms));
568
+ }
569
+
570
+ // ── the profile family: config-file only, never a network call ─────────────────────────
571
+
572
+ /** One `pmcp profile …` invocation, normalized from argv. */
573
+
574
+
575
+
576
+
577
+
578
+
579
+ /**
580
+ * `pmcp profile add|list|use|remove` — the profile store as a command surface (§10). Never
581
+ * touches the network: `add` writes a url alone (login fills the token) and NEVER destroys
582
+ * a credential — a url change on a profile that already holds a token warns instead of
583
+ * clearing it; `use` moves the top-level default; `remove` drops one table, and refuses to
584
+ * drop the ACTIVE one without `--yes`.
585
+ */
586
+ export async function profile(cmd ) {
587
+ const config = readConfig();
588
+ const resolved = resolveActiveProfile(globals.profile);
589
+ if (cmd.sub === "list") {
590
+ const names = Object.keys(config.profiles).sort();
591
+ if (globals.json) {
592
+ return emitDocument({
593
+ active: resolved.name,
594
+ activeSource: resolved.source,
595
+ profiles: names.map((name) => ({
596
+ name,
597
+ url: config.profiles[name].url ?? "",
598
+ token: (config.profiles[name].token ?? "") !== "",
599
+ bootstrapSecret: (config.profiles[name].bootstrap_secret ?? "") !== "",
600
+ })),
601
+ });
602
+ }
603
+ const c = styling(decorated());
604
+ const table = columnize(
605
+ names.map((name) => {
606
+ const entry = config.profiles[name];
607
+ const state =
608
+ (entry.token ?? "") !== "" ? "logged in" : (entry.bootstrap_secret ?? "") !== "" ? "bootstrap only" : "no token";
609
+ return [`${name === resolved.name ? "*" : " "} ${name}`, entry.url ?? "", state];
610
+ }),
611
+ { tty: decorated() },
612
+ );
613
+ write(table === "" ? "no profiles yet: pmcp login --url https://…\n" : `${c.reset(table)}\n`);
614
+ return 0;
615
+ }
616
+ if (cmd.sub === "add") {
617
+ const existing = config.profiles[cmd.name];
618
+ const hadToken = (existing?.token ?? "") !== "";
619
+ const changedUrl = existing !== undefined && (existing.url ?? "") !== cmd.url;
620
+ (config.profiles[cmd.name] ??= {}).url = cmd.url;
621
+ writeConfig(config);
622
+ if (globals.json) {
623
+ return emitDocument({ name: cmd.name, url: cmd.url, token: hadToken });
624
+ }
625
+ write(`profile ${cmd.name} → ${cmd.url}\n`);
626
+ if (hadToken && changedUrl) write(`token was issued by the previous origin: pmcp login --profile ${cmd.name}\n`);
627
+ else if (!hadToken) write(`no token yet: pmcp login --profile ${cmd.name}\n`);
628
+ return 0;
629
+ }
630
+ if (cmd.sub === "use") {
631
+ if (config.profiles[cmd.name] === undefined) {
632
+ throw new CliError("not_found", `no profile "${cmd.name}"`, { hints: ["pmcp profile list"] });
633
+ }
634
+ config.profile = cmd.name;
635
+ writeConfig(config);
636
+ if (globals.json) return emitDocument({ active: cmd.name, activeSource: "config" });
637
+ write(`default profile → ${cmd.name}\n`);
638
+ return 0;
639
+ }
640
+ if (config.profiles[cmd.name] === undefined) {
641
+ throw new CliError("not_found", `no profile "${cmd.name}"`, { hints: ["pmcp profile list"] });
642
+ }
643
+ if (cmd.name === resolved.name && !globals.yes) {
644
+ if (!(await confirm(`remove the ACTIVE profile ${cmd.name}? its stored token goes with it`))) return 1;
645
+ }
646
+ delete config.profiles[cmd.name];
647
+ if (config.profile === cmd.name) delete config.profile;
648
+ writeConfig(config);
649
+ if (globals.json) return emitDocument({ removed: cmd.name });
650
+ write(`removed ${cmd.name}\n`);
651
+ return 0;
652
+ }
653
+
654
+ // ── the sugar: every other command is one or two admin ops ─────────────────────────────
655
+
656
+ /**
657
+ * `pmcp ls` — the namespace at a glance: every service with kind, status
658
+ * (online/offline for tunneled; not-connected/connected/needs-reconnect for
659
+ * `auth: oauth` proxied, plain "proxy" otherwise), declared roles, and
660
+ * archived flag; the builtin `pmcp` row shows as builtin. Sugar over
661
+ * service_list — with a service-account key it fails like every admin-backed
662
+ * command, since accounts never hold `pmcp` grants (§8, §10).
663
+ *
664
+ * `--json` passes the `service_list` rows through untouched: wire vocabulary, wire fields,
665
+ * nothing renamed (§10).
666
+ */
667
+ export async function ls(ctx ) {
668
+ // deps: mcpCall · render.columnize
669
+ const services = rows (await adminOp(ctx, "service_list"), "services");
670
+ if (globals.json) return emitDocument({ services });
671
+ const c = styling(decorated());
672
+ const table = columnize(
673
+ services.map((row) => [
674
+ row.slug,
675
+ row.kind,
676
+ statusOf(row),
677
+ declaredRoles(row),
678
+ row.archived ? "(archived)" : "",
679
+ ]),
680
+ {
681
+ headers: ["SERVICE", "KIND", "STATUS", "ROLES", ""],
682
+ tty: decorated(),
683
+ // Painted per CELL: a slug that happens to contain the status word (`online-notes`,
684
+ // `proxy-cache`) would make a search-and-replace over the rendered line colour the
685
+ // wrong column and then slice through the escape it had just inserted.
686
+ style: (cell, column) =>
687
+ column === 0 ? c.bold(cell) : column === 2 ? statusColor(c, cell)(cell) : cell,
688
+ },
689
+ ).split("\n");
690
+ write(`${c.dim(table[0])}\n`);
691
+ for (const line of table.slice(1)) write(`${line}\n`);
692
+ return 0;
693
+ }
694
+
695
+ /** §10's status colours: online green, offline red, builtin dim, anything else unpainted. */
696
+ function statusColor(c , status ) {
697
+ if (status === "online") return c.green;
698
+ if (status === "offline") return c.red;
699
+ if (status === "builtin") return c.dim;
700
+ return (value) => value;
701
+ }
702
+
703
+ /** The wire's own status word for a row — never a CLI-private respelling (§10). */
704
+ function statusOf(row ) {
705
+ if (row.builtin === true || row.kind === "builtin") return "builtin";
706
+ if (row.kind === "proxy") return row.connection ?? "proxy";
707
+ return row.status ?? "";
708
+ }
709
+
710
+ function declaredRoles(row ) {
711
+ const declared = Object.keys(row.roles ?? {});
712
+ return declared.length === 0 ? "-" : declared.join(", ");
713
+ }
714
+
715
+ /**
716
+ * `pmcp tools <service>` (hidden alias; `describe service/<slug>` is the documented
717
+ * surface) — the service's tools/list exactly as the current token sees it (hub-filtered
718
+ * by grants, unprefixed names). Hub errors pass through as sent.
719
+ */
720
+ export async function tools(ctx , service ) {
721
+ // deps: mcpList
722
+ const listed = (await mcpList(ctx, service)) ;
723
+ if (globals.json) return emitDocument({ service, tools: listed });
724
+ for (const tool of listed) write(`${catalogLine(String(tool.name), String(tool.description ?? ""), 28, decorated())}\n`);
725
+ return 0;
726
+ }
727
+
728
+ /**
729
+ * `pmcp call` — one tools/call against the scoped endpoint, result JSON to
730
+ * stdout. `target` arrives already split by main (`<slug>_<tool>` aggregated
731
+ * names split at the first `_`, unambiguous because slugs contain no
732
+ * underscore, §7); `args` is the parsed `--args`/`key=value` object, sent
733
+ * verbatim. A result carrying `isError: true` is still PRINTED and exits 1 (§10). A hub
734
+ * refusal is enriched once, on the error path only, with what the caller should have sent.
735
+ */
736
+ export async function call(
737
+ ctx ,
738
+ target ,
739
+ args ,
740
+ ) {
741
+ // deps: mcpCall · enrichCallFailure
742
+ try {
743
+ const result = (await mcpCall(ctx, target.service, target.tool, args)) ;
744
+ write(`${renderJson(result, documentColor())}\n`);
745
+ return result?.isError === true ? 1 : 0;
746
+ } catch (error) {
747
+ throw await enrichCallFailure(ctx, target, args, error);
748
+ }
749
+ }
750
+
751
+ /**
752
+ * §10's "one best-effort enrichment fetch on the error path only": a hub refusal is turned
753
+ * into a CliError that says what the caller should have sent, using the catalog it did not
754
+ * consult before the call. Every fetch here is wrapped — an enrichment that itself fails
755
+ * silently degrades to the bare refusal, and none of it ever runs on the happy path.
756
+ */
757
+ async function enrichCallFailure(
758
+ ctx ,
759
+ target ,
760
+ args ,
761
+ error ,
762
+ ) {
763
+ if (!(error instanceof HubRpcError)) return error;
764
+ if (error.code === HUB_ERRORS.approvalRequired) {
765
+ const data = error.data ;
766
+ return new CliError("approval_required", `approval required (${data.approvalId})`, {
767
+ detail: [`approve at ${data.approvalUrl}`, `then re-run this exact call before ${data.expiresAt} — the arguments must be identical`],
768
+ extra: { approvalId: data.approvalId, approvalUrl: data.approvalUrl, expiresAt: data.expiresAt },
769
+ });
770
+ }
771
+ const catalog = await catalogOf(ctx, target.service);
772
+ if (error.code === HUB_ERRORS.toolNotPermitted) {
773
+ if (catalog === undefined) {
774
+ // The service itself did not answer: the slug, not the tool, is what is wrong.
775
+ const slugs = await serviceSlugs(ctx);
776
+ const suggestion = slugs === undefined ? undefined : didYouMean(target.service, slugs);
777
+ return new CliError("not_found", `no service "${target.service}" in your namespace`, {
778
+ detail: suggestion === undefined ? [] : [`did you mean "${suggestion}"?`],
779
+ hints: ["pmcp ls lists your services"],
780
+ extra: suggestion === undefined ? undefined : { didYouMean: suggestion },
781
+ });
782
+ }
783
+ const suggestion = didYouMean(target.tool, catalog.map((tool) => String(tool.name)));
784
+ return new CliError("not_found", `no tool "${target.tool}" on ${target.service}`, {
785
+ detail: suggestion === undefined ? [] : [`did you mean "${suggestion}"?`],
786
+ hints: [`pmcp describe service/${target.service} lists everything it serves`],
787
+ extra: suggestion === undefined ? undefined : { didYouMean: suggestion },
788
+ });
789
+ }
790
+ if (error.code === HUB_ERRORS.invalidParams) {
791
+ const descriptor = catalog?.find((tool) => String(tool.name) === target.tool);
792
+ const schema = descriptor?.inputSchema ;
793
+ const known = Object.keys(schema?.properties ?? {});
794
+ const unknownArg = Object.keys(args).find((key) => known.length > 0 && !known.includes(key));
795
+ const suggestion = unknownArg === undefined ? undefined : didYouMean(unknownArg, known);
796
+ const detail = [];
797
+ if (suggestion !== undefined) detail.push(`did you mean "${suggestion}"?`);
798
+ if (schema !== undefined) detail.push(`${target.tool} expects\n${indent(schemaTable(schema, decorated()), 2)}`);
799
+ return new CliError("invalid_arguments", error.message, {
800
+ detail,
801
+ hints: [`pmcp describe service/${target.service}/${target.tool}`],
802
+ extra:
803
+ schema === undefined && suggestion === undefined
804
+ ? undefined
805
+ : { ...(suggestion === undefined ? {} : { didYouMean: suggestion }), ...(schema === undefined ? {} : { expectedArguments: schema }) },
806
+ });
807
+ }
808
+ return error;
809
+ }
810
+
811
+ /** The service's tools/list, or undefined when the fetch itself failed (best effort, §10). */
812
+ async function catalogOf(ctx , service ) {
813
+ try {
814
+ return (await mcpList(ctx, service)) ;
815
+ } catch {
816
+ return undefined;
817
+ }
818
+ }
819
+
820
+ /** Every slug in the namespace, or undefined — a service-account key cannot read this (§8). */
821
+ async function serviceSlugs(ctx ) {
822
+ try {
823
+ return rows (await adminOp(ctx, "service_list"), "services").map((row) => row.slug);
824
+ } catch {
825
+ return undefined;
826
+ }
827
+ }
828
+
829
+ function indent(text , spaces ) {
830
+ const pad = " ".repeat(spaces);
831
+ return text
832
+ .split("\n")
833
+ .map((line) => (line === "" ? line : pad + line))
834
+ .join("\n");
835
+ }
836
+
837
+ // ── §20.6: the data-model commands, gateway sugar of exactly the kind `tools`/`call`
838
+ // already are — they front an MCP method on the scoped endpoint, never an admin op ─────
839
+
840
+ /**
841
+ * `pmcp prompts <service>` (hidden alias of `describe`) — `prompts/list` on the SCOPED
842
+ * endpoint (§20.2/§20.6): only there does a prompt keep the unprefixed name the service
843
+ * gave it. One row per prompt, name then description.
844
+ */
845
+ export async function prompts(ctx , service ) {
846
+ // deps: rpc
847
+ const result = (await rpc(ctx, scoped(ctx, service), "prompts/list")) ;
848
+ const listed = (result?.prompts ?? []) ;
849
+ if (globals.json) return emitDocument({ service, prompts: listed });
850
+ for (const entry of listed) write(`${catalogLine(String(entry.name), String(entry.description ?? ""), 28, decorated())}\n`);
851
+ return 0;
852
+ }
853
+
854
+ /**
855
+ * `pmcp get prompt/<service>/<name> [key=value …]` (and the hidden `pmcp prompt` alias) —
856
+ * `prompts/get` on the scoped endpoint, the `key=value` grammar `pmcp call` already speaks
857
+ * landing exactly where the method declares it: `params.arguments`, beside the prompt's own
858
+ * `name` and nowhere else.
859
+ */
860
+ export async function prompt(
861
+ ctx ,
862
+ service ,
863
+ name ,
864
+ args ,
865
+ ) {
866
+ // deps: rpc
867
+ const result = await rpc(ctx, scoped(ctx, service), "prompts/get", { name, arguments: args });
868
+ write(`${renderJson(result, documentColor())}\n`);
869
+ return 0;
870
+ }
871
+
872
+ /**
873
+ * `pmcp resources <service> [--templates]` (hidden alias of `describe`) — `resources/list`
874
+ * on the scoped endpoint, or `resources/templates/list` when `--templates` is given
875
+ * (§20.2/§20.6). §20.2 keys this family by `uri`, never by `name`, so each row prints the
876
+ * uri; a template row prints the RAW `uriTemplate`, unexpanded.
877
+ */
878
+ export async function resources(ctx , service , opts ) {
879
+ // deps: rpc
880
+ if (opts.templates === true) {
881
+ const result = (await rpc(ctx, scoped(ctx, service), "resources/templates/list")) ;
882
+ const listed = (result?.resourceTemplates ?? []) ;
883
+ if (globals.json) return emitDocument({ service, resourceTemplates: listed });
884
+ for (const template of listed) write(`${String(template.uriTemplate)}\n`);
885
+ return 0;
886
+ }
887
+ const result = (await rpc(ctx, scoped(ctx, service), "resources/list")) ;
888
+ const listed = (result?.resources ?? []) ;
889
+ if (globals.json) return emitDocument({ service, resources: listed });
890
+ for (const resource of listed) write(`${String(resource.uri)}\n`);
891
+ return 0;
892
+ }
893
+
894
+ /**
895
+ * `pmcp get resource/<service>/<uri>` (and the hidden `pmcp read` alias) — `resources/read`
896
+ * on the SLUG's scoped endpoint, the URI sent verbatim as `params.uri`: never
897
+ * percent-encoded (it is a param value, not part of the URL) and never `<slug>_`-prefixed
898
+ * (§20.2 refuses the aggregated endpoint precisely because a URI cannot take a prefix and
899
+ * still be the URI the service knows). Routed by the addressed slug alone, never by the
900
+ * URI's own scheme — two services may legitimately serve the identical URI (§20.2).
901
+ */
902
+ export async function read(ctx , service , uri ) {
903
+ // deps: rpc
904
+ const result = await rpc(ctx, scoped(ctx, service), "resources/read", { uri });
905
+ write(`${renderJson(result, documentColor())}\n`);
906
+ return 0;
907
+ }
908
+
909
+ // ── describe: one path-style ref, four catalog families, two entity kinds ───────────────
910
+
911
+ /** A ref as `describe`/`get` split it: the first two slashes only, so a URI item keeps its own. */
912
+
913
+
914
+ /**
915
+ * §10's ref grammar: the FIRST segment names the kind of thing, splitting stops after the
916
+ * second slash. `kinds` is the vocabulary the calling verb accepts — an unknown first
917
+ * segment is a `usage` error carrying the corrected spelling, never a network call.
918
+ */
919
+ export function parseRef(ref , kinds , verb ) {
920
+ const first = ref.indexOf("/");
921
+ const kind = first === -1 ? ref : ref.slice(0, first);
922
+ if (!kinds.includes(kind)) {
923
+ const suggestion = didYouMean(kind, kinds);
924
+ const rest = first === -1 ? "" : ref.slice(first + 1);
925
+ throw new CliError("usage", `unknown ref type "${kind}" (valid: ${kinds.join(", ")})`, {
926
+ hints: suggestion === undefined ? [`pmcp ${verb} <${kinds.join("|")}>/…`] : [`pmcp ${verb} ${suggestion}/${rest}`],
927
+ extra: suggestion === undefined ? undefined : { didYouMean: suggestion },
928
+ });
929
+ }
930
+ const rest = first === -1 ? "" : ref.slice(first + 1);
931
+ if (rest === "") throw new CliError("usage", `ref "${ref}" names no ${kind}`, { hints: [`pmcp ${verb} ${kind}/<slug>`] });
932
+ const second = rest.indexOf("/");
933
+ return second === -1
934
+ ? { kind, slug: rest }
935
+ : { kind, slug: rest.slice(0, second), item: rest.slice(second + 1) };
936
+ }
937
+
938
+ /** The four catalog families §20.2 defines, and the key each one is addressed by. */
939
+ const FAMILIES = [
940
+ { key: "tools", label: "tools", singular: "tool", method: "tools/list", resultKey: "tools", idOf: (item ) => String(item.name) },
941
+ { key: "prompts", label: "prompts", singular: "prompt", method: "prompts/list", resultKey: "prompts", idOf: (item ) => String(item.name) },
942
+ { key: "resources", label: "resources", singular: "resource", method: "resources/list", resultKey: "resources", idOf: (item ) => String(item.uri) },
943
+ {
944
+ key: "resourceTemplates",
945
+ label: "templates",
946
+ singular: "template",
947
+ method: "resources/templates/list",
948
+ resultKey: "resourceTemplates",
949
+ idOf: (item ) => String(item.uriTemplate),
950
+ },
951
+ ] ;
952
+
953
+
954
+
955
+ /**
956
+ * All four gateway lists for one service. A family the service does not serve answers
957
+ * `-32601` (§20.2's method-not-found) and becomes an empty array rather than a failure —
958
+ * `describe` is family-agnostic and prints `(none)` for what is absent.
959
+ */
960
+ async function readCatalog(ctx , slug ) {
961
+ const entries = await Promise.all(
962
+ FAMILIES.map(async (family) => {
963
+ try {
964
+ const result = (await rpc(ctx, scoped(ctx, slug), family.method)) ;
965
+ return [family.key, ((result?.[family.resultKey] ?? []) )] ;
966
+ } catch (error) {
967
+ if (error instanceof HubRpcError && error.code === HUB_ERRORS.methodNotFound) return [family.key, []] ;
968
+ throw error;
969
+ }
970
+ }),
971
+ );
972
+ return Object.fromEntries(entries);
973
+ }
974
+
975
+ /**
976
+ * `pmcp describe <ref>` — §10's one exploration verb. `service/<slug>` renders from the
977
+ * four GATEWAY list calls alone, so it works with any token including a `pmcp_sa_` key; its
978
+ * kind/status/roles header is a best-effort admin read that degrades to the bare slug when
979
+ * refused. `service/<slug>/<item>` matches tools and prompts by name, resources by `uri`
980
+ * and templates by `uriTemplate`, and prints EVERY match. `account/<slug>` composes
981
+ * `account_list` + `token_list` — the same reads the admin commands already make.
982
+ */
983
+ export async function describe(ctx , ref ) {
984
+ if (ref.kind === "account") return describeAccount(ctx, ref.slug);
985
+ const catalog = await readCatalog(ctx, ref.slug);
986
+ if (ref.item !== undefined) return describeItem(ref.slug, ref.item, catalog);
987
+ // Best effort, and only for the header: a service account can read the catalog above but
988
+ // never `service_list` (§8), and the catalog is the part that matters.
989
+ const row = await serviceRow(ctx, ref.slug);
990
+ if (globals.json) {
991
+ return emitDocument({
992
+ service: ref.slug,
993
+ ...(row === undefined ? {} : { kind: row.kind, status: statusOf(row), roles: Object.keys(row.roles ?? {}), archived: row.archived }),
994
+ tools: catalog.tools,
995
+ prompts: catalog.prompts,
996
+ resources: catalog.resources,
997
+ resourceTemplates: catalog.resourceTemplates,
998
+ });
999
+ }
1000
+ const c = styling(decorated());
1001
+ write(
1002
+ row === undefined
1003
+ ? `${c.bold(ref.slug)}\n`
1004
+ : `${c.bold(ref.slug)} — ${row.kind}, ${statusOf(row)} — roles: ${declaredRoles(row)}${row.archived ? " (archived)" : ""}\n`,
1005
+ );
1006
+ if (row !== undefined && row.kind === "tunnel" && row.status !== "online") {
1007
+ write(`${c.yellow("offline — catalog from last connection")}\n`);
1008
+ }
1009
+ const empty = [];
1010
+ for (const family of FAMILIES) {
1011
+ const items = catalog[family.key];
1012
+ if (items.length === 0) {
1013
+ empty.push([family.label, "(none)"]);
1014
+ continue;
1015
+ }
1016
+ write(`\n${c.dim(family.label)}\n`);
1017
+ for (const item of items) {
1018
+ write(` ${catalogLine(family.idOf(item), String(item.description ?? item.name ?? ""), 18, decorated())}\n`);
1019
+ }
1020
+ }
1021
+ if (empty.length > 0) write(`\n${columnize(empty, { tty: decorated() })}\n`);
1022
+ write(`\npmcp describe service/${ref.slug}/<item> shows an item's full shape\n`);
1023
+ return 0;
1024
+ }
1025
+
1026
+ /** The admin row behind a service, or undefined when the caller may not read one (§8). */
1027
+ async function serviceRow(ctx , slug ) {
1028
+ try {
1029
+ return rows (await adminOp(ctx, "service_list"), "services").find((row) => row.slug === slug);
1030
+ } catch {
1031
+ return undefined;
1032
+ }
1033
+ }
1034
+
1035
+ /** Every family match for one item id, printed under its family header; none is `not_found`. */
1036
+ function describeItem(slug , item , catalog ) {
1037
+ const matches = FAMILIES.flatMap((family) =>
1038
+ catalog[family.key].filter((entry) => family.idOf(entry) === item).map((entry) => ({ family, entry })),
1039
+ );
1040
+ if (matches.length === 0) {
1041
+ const all = FAMILIES.flatMap((family) => catalog[family.key].map((entry) => ({ id: family.idOf(entry), family })));
1042
+ const suggestion = didYouMean(item, all.map((candidate) => candidate.id));
1043
+ const closest = all.find((candidate) => candidate.id === suggestion);
1044
+ throw new CliError(
1045
+ "not_found",
1046
+ `nothing named "${item}" on ${slug} (searched ${FAMILIES.map((family) => family.label).join(", ")})`,
1047
+ {
1048
+ detail: closest === undefined ? [] : [`closest: ${closest.id} (${closest.family.singular})`],
1049
+ hints: [`pmcp describe service/${slug} lists everything`],
1050
+ extra: closest === undefined ? undefined : { didYouMean: closest.id },
1051
+ },
1052
+ );
1053
+ }
1054
+ if (globals.json) {
1055
+ return emitDocument({
1056
+ service: slug,
1057
+ matches: matches.map(({ family, entry }) => ({ family: family.key, ...entry })),
1058
+ });
1059
+ }
1060
+ const c = styling(decorated());
1061
+ matches.forEach(({ family, entry }, index) => {
1062
+ if (index > 0) write("\n");
1063
+ write(`${c.bold(family.idOf(entry))} — ${family.singular} on ${slug}\n`);
1064
+ if (typeof entry.description === "string" && entry.description !== "") {
1065
+ write(`\n${wrapText(entry.description, 78, 2)}\n`);
1066
+ }
1067
+ for (const [label, value] of [["uri", entry.uri], ["uriTemplate", entry.uriTemplate], ["name", entry.name], ["mimeType", entry.mimeType]] ) {
1068
+ if (family.key === "tools" || family.key === "prompts") break;
1069
+ if (typeof value === "string" && value !== "") write(`\n${label}\n ${value}\n`);
1070
+ }
1071
+ if (Array.isArray(entry.arguments)) {
1072
+ write(`\n${c.dim("arguments")}\n`);
1073
+ write(
1074
+ `${indent(
1075
+ columnize(
1076
+ (entry.arguments ).map((argument) => [
1077
+ String(argument.name),
1078
+ argument.required === true ? "required" : "",
1079
+ String(argument.description ?? ""),
1080
+ ]),
1081
+ { tty: decorated() },
1082
+ ),
1083
+ 2,
1084
+ )}\n`,
1085
+ );
1086
+ }
1087
+ if (entry.inputSchema !== undefined) {
1088
+ write(`\n${c.dim("arguments")}\n${indent(schemaTable(entry.inputSchema, decorated()), 2)}\n`);
1089
+ }
1090
+ if (entry.outputSchema !== undefined) {
1091
+ write(`\n${c.dim("returns")}\n${indent(schemaTable(entry.outputSchema, decorated()), 2)}\n`);
1092
+ }
1093
+ });
1094
+ return 0;
1095
+ }
1096
+
1097
+ /** `describe account/<slug>` — account_list + token_list, the same reads admin already makes. */
1098
+ async function describeAccount(ctx , slug ) {
1099
+ const found = rows (await adminOp(ctx, "account_list"), "accounts").find((row) => row.slug === slug);
1100
+ if (found === undefined) {
1101
+ throw new CliError("not_found", `no service account "${slug}"`, { hints: ["pmcp account list"] });
1102
+ }
1103
+ const tokens = rows (await adminOp(ctx, "token_list"), "tokens").filter(
1104
+ (row) => row.kind === "service_account" && row.refSlug === slug,
1105
+ );
1106
+ if (globals.json) return emitDocument({ account: found, tokens });
1107
+ const c = styling(decorated());
1108
+ write(`${c.bold(slug)} — service account\n`);
1109
+ const grants = Object.entries(found.grants).flatMap(([service, roles]) =>
1110
+ roles.map((role) => {
1111
+ const split = splitGrant(role);
1112
+ return [service, split.role, split.mode];
1113
+ }),
1114
+ );
1115
+ write(`\n${c.dim("grants")}\n${indent(grants.length === 0 ? "(none)" : columnize(grants, { tty: decorated() }), 2)}\n`);
1116
+ const tokenRows = tokens.map((row) => [
1117
+ String(row.id),
1118
+ String(row.prefix ?? ""),
1119
+ `expires ${row.expiresAt === null || row.expiresAt === undefined ? "never" : formatDate(Number(row.expiresAt))}`,
1120
+ `last used ${row.lastUsedAt === null || row.lastUsedAt === undefined ? "never" : formatDate(Number(row.lastUsedAt))}`,
1121
+ ]);
1122
+ write(`\n${c.dim("tokens")}\n${indent(tokenRows.length === 0 ? "(none)" : columnize(tokenRows, { tty: decorated() }), 2)}\n`);
1123
+ return 0;
1124
+ }
1125
+
1126
+ // ── the imperative admin families ──────────────────────────────────────────────────────
1127
+
1128
+ /**
1129
+ * One imperative service command, normalized from `pmcp service …` argv by
1130
+ * main. `create` of a tunneled service is two tool calls — service_create,
1131
+ * then token_issue — because a tunneled service is unusable without its token
1132
+ * (§6 lifecycle); proxied create carries endpoint + auth mode instead.
1133
+ * `set-auth` holds the full replacement header set (repeatable `--header`
1134
+ * flags, write-only, headers-mode services only, §8); `disconnect` wipes an
1135
+ * OAuth bundle (`auth: oauth` only).
1136
+ */
1137
+
1138
+
1139
+
1140
+
1141
+
1142
+
1143
+ /**
1144
+ * `pmcp service …` — the one-off actions the web UI does with buttons (§10);
1145
+ * declarative management belongs to diff/apply. Each sub maps onto its §8
1146
+ * tool. Tunneled `create` prints the minted service token exactly once — the
1147
+ * CLI never stores it. `delete` is destructive (grants cascade, tokens
1148
+ * deleted, socket severed — all server-side effects) and asks for
1149
+ * confirmation unless `--yes`. The reserved `pmcp` slug is rejected uniformly by
1150
+ * the server; no client-side gate duplicates that.
1151
+ */
1152
+ export async function service(ctx , cmd ) {
1153
+ // deps: mcpCall · confirm
1154
+ if (cmd.sub === "create") {
1155
+ const created = await adminOp(ctx, "service_create", {
1156
+ slug: cmd.slug,
1157
+ kind: cmd.kind,
1158
+ ...(cmd.kind === "proxy" ? { endpoint: cmd.endpoint, auth: cmd.auth } : {}),
1159
+ });
1160
+ // A tunneled service is unusable without its credential (§6): mint it here, print once.
1161
+ const minted = cmd.kind === "tunnel" ? await adminOp(ctx, "token_issue", { kind: "service", slug: cmd.slug }) : undefined;
1162
+ if (globals.json) return emitDocument({ service: created.service ?? { slug: cmd.slug }, ...(minted === undefined ? {} : { token: minted }) });
1163
+ write(`created ${String((created.service )?.slug ?? cmd.slug)}\n`);
1164
+ if (minted !== undefined) write(`service token (shown once): ${String(minted.token)}\n`);
1165
+ return 0;
1166
+ }
1167
+ if (cmd.sub === "set-auth") {
1168
+ await adminOp(ctx, "service_set_upstream_auth", { slug: cmd.slug, headers: cmd.headers });
1169
+ if (globals.json) return emitDocument({ slug: cmd.slug, headers: Object.keys(cmd.headers) });
1170
+ write(`upstream headers replaced for ${cmd.slug}\n`);
1171
+ return 0;
1172
+ }
1173
+ if (cmd.sub === "delete" && !globals.yes) {
1174
+ if (!(await confirm(`delete ${cmd.slug}? its grants cascade and its tokens are deleted`))) return 1;
1175
+ }
1176
+ const op = { archive: "service_archive", unarchive: "service_unarchive", delete: "service_delete", disconnect: "service_disconnect" }[
1177
+ cmd.sub
1178
+ ];
1179
+ await adminOp(ctx, op, { slug: cmd.slug });
1180
+ if (globals.json) return emitDocument({ slug: cmd.slug, action: cmd.sub });
1181
+ write(`${cmd.sub} ${cmd.slug}\n`);
1182
+ return 0;
1183
+ }
1184
+
1185
+ /** One service-account command, normalized from `pmcp account …` argv. */
1186
+
1187
+
1188
+
1189
+
1190
+
1191
+ /**
1192
+ * `pmcp account …` — sugar over account_list / account_create /
1193
+ * account_delete (§8). `list` prints each account with its grants inline (per
1194
+ * service: role names and modes) — the same single read the diff planner
1195
+ * rides. `delete` is destructive — grants cascade and the account's tokens are
1196
+ * deleted server-side — and asks for confirmation unless `--yes`.
1197
+ */
1198
+ export async function account(ctx , cmd ) {
1199
+ // deps: mcpCall · confirm
1200
+ if (cmd.sub === "list") {
1201
+ const accounts = rows (await adminOp(ctx, "account_list"), "accounts");
1202
+ if (globals.json) return emitDocument({ accounts });
1203
+ const table = columnize(
1204
+ accounts.map((row) => [
1205
+ row.slug,
1206
+ Object.entries(row.grants)
1207
+ .map(([svc, roles]) => `${svc}=[${roles.join(",")}]`)
1208
+ .join(" ") || "(no grants)",
1209
+ ]),
1210
+ { headers: ["ACCOUNT", "GRANTS"], tty: decorated() },
1211
+ );
1212
+ write(`${table}\n`);
1213
+ return 0;
1214
+ }
1215
+ if (cmd.sub === "create") {
1216
+ const created = await adminOp(ctx, "account_create", {
1217
+ slug: cmd.slug,
1218
+ ...(cmd.name === undefined ? {} : { name: cmd.name }),
1219
+ ...(cmd.description === undefined ? {} : { description: cmd.description }),
1220
+ });
1221
+ if (globals.json) return emitDocument(created);
1222
+ write(`created ${cmd.slug}\n`);
1223
+ return 0;
1224
+ }
1225
+ if (!globals.yes) {
1226
+ if (!(await confirm(`delete account ${cmd.slug}? its grants cascade and its tokens are deleted`))) return 1;
1227
+ }
1228
+ await adminOp(ctx, "account_delete", { slug: cmd.slug });
1229
+ if (globals.json) return emitDocument({ slug: cmd.slug, action: "delete" });
1230
+ write(`deleted ${cmd.slug}\n`);
1231
+ return 0;
1232
+ }
1233
+
1234
+ /**
1235
+ * One approval command: `list` fronts approval_list (default everything,
1236
+ * newest first; `filter` narrows), `approve`/`reject` front approval_decide.
1237
+ */
1238
+
1239
+
1240
+
1241
+
1242
+ /**
1243
+ * `pmcp approvals | approve | reject` (§10) — the CLI front of the approval
1244
+ * dashboard (§8's approval_list / approval_decide; the web page is the other
1245
+ * front). The rendering reads the WIRE's own field names — `accountSlug`,
1246
+ * `serviceSlug`, `args` (approvals.ApprovalRow) — and prefixes `sa:` client-side, so the
1247
+ * WHO/WHAT columns carry what the hub actually sent.
1248
+ */
1249
+ export async function approval(ctx , cmd ) {
1250
+ // deps: mcpCall
1251
+ if (cmd.sub !== "list") {
1252
+ const decided = await withIdPrefix(ctx, cmd.id, { op: "approval_list", key: "approvals" }, (id) =>
1253
+ adminOp(ctx, "approval_decide", { id, decision: cmd.sub }),
1254
+ );
1255
+ if (globals.json) return emitDocument(decided);
1256
+ write(`${String(decided.decision ?? cmd.sub)} ${String(decided.id ?? cmd.id)}\n`);
1257
+ return 0;
1258
+ }
1259
+ // `--history` is a CLIENT-side selection: approval_list's `status` is the wire enum
1260
+ // (pending/approved/rejected/expired/used) and has no "decided" member, so asking the hub
1261
+ // for one is a frame it can only refuse (§8's op schema).
1262
+ const listed = await adminOp(ctx, "approval_list", cmd.filter === "pending" ? { status: "pending" } : {});
1263
+ const approvals = ((listed.approvals ?? listed.rows ?? []) ).filter(
1264
+ (row) => cmd.filter !== "history" || String(row.status ?? "") !== "pending",
1265
+ );
1266
+ if (globals.json) return emitDocument({ approvals });
1267
+ const c = styling(decorated());
1268
+ const table = columnize(
1269
+ approvals.flatMap((row) => [
1270
+ [String(row.id), String(row.status ?? ""), `sa:${String(row.accountSlug ?? "")} → ${String(row.serviceSlug ?? "")}/${String(row.tool ?? "")}`],
1271
+ ["", "", `args ${JSON.stringify(row.args ?? {})} · ${expiryPhrase(row.expiresAt)}`],
1272
+ ]),
1273
+ { headers: ["APPROVAL", "STATUS", "WHO → WHAT"], tty: decorated() },
1274
+ ).split("\n");
1275
+ write(`${c.dim(table[0])}\n`);
1276
+ for (const line of table.slice(1)) write(`${line.trimEnd()}\n`);
1277
+ return 0;
1278
+ }
1279
+
1280
+ /**
1281
+ * The mutating verbs accept any unambiguous id PREFIX (§10's `ambiguous_id` code, mock §6),
1282
+ * resolved on the ERROR path only: the typed id goes out exactly as typed, and only a
1283
+ * refusal buys the one list call that turns a prefix into the full id. Nothing is retried
1284
+ * after a mutation that landed — the retry happens only because the first attempt failed —
1285
+ * and an id that is already exact, or a refusal about something other than the id, rethrows
1286
+ * the hub's own error untouched.
1287
+ */
1288
+ async function withIdPrefix (
1289
+ ctx ,
1290
+ prefix ,
1291
+ list ,
1292
+ run ,
1293
+ ) {
1294
+ try {
1295
+ return await run(prefix);
1296
+ } catch (error) {
1297
+ if (!(error instanceof HubRpcError)) throw error;
1298
+ const ids = await idsMatching(ctx, prefix, list);
1299
+ if (ids.length === 0 || ids[0] === prefix) throw error;
1300
+ if (ids.length > 1) {
1301
+ throw new CliError("ambiguous_id", `"${prefix}" matches ${ids.length} ids`, {
1302
+ detail: ids,
1303
+ hints: ["pass more of the id"],
1304
+ });
1305
+ }
1306
+ return run(ids[0]);
1307
+ }
1308
+ }
1309
+
1310
+ /** The ids one list op carries that start with `prefix`, or none when the list itself failed. */
1311
+ async function idsMatching(ctx , prefix , list ) {
1312
+ try {
1313
+ return rows (await adminOp(ctx, list.op), list.key)
1314
+ .map((row) => String(row.id ?? ""))
1315
+ .filter((id) => id !== "" && id.startsWith(prefix));
1316
+ } catch {
1317
+ return [];
1318
+ }
1319
+ }
1320
+
1321
+ /** An ISO-8601 expiry as the humane phrase the mock prints — `expires in 9m`, or a date. */
1322
+ function expiryPhrase(expiresAt ) {
1323
+ if (typeof expiresAt !== "string" || expiresAt === "") return "no expiry";
1324
+ const at = Date.parse(expiresAt);
1325
+ if (Number.isNaN(at)) return `expires ${expiresAt}`;
1326
+ const minutes = Math.round((at - Date.now()) / 60_000);
1327
+ if (minutes < 0) return "expired";
1328
+ return minutes < 60 ? `expires in ${minutes}m` : `expires ${formatDateTime(at)}`;
1329
+ }
1330
+
1331
+ /**
1332
+ * One token command, normalized from `pmcp token …` argv: `issue` targets a
1333
+ * service account or a (tunneled) service by slug; `expires` arrives ALREADY
1334
+ * RESOLVED by main's expiresIn to what token_issue declares — a count of
1335
+ * SECONDS of lifetime, or the literal `never` — so the human spelling "90d"
1336
+ * never reaches this type (defaults differ by kind, §5).
1337
+ */
1338
+
1339
+
1340
+
1341
+
1342
+
1343
+ /**
1344
+ * `pmcp token …` — sugar over token_issue / token_list / token_revoke (§8).
1345
+ * `issue` prints the plaintext key exactly once and the CLI never stores it.
1346
+ * `list` shows prefix, expiry, and the coarse last_used_at that makes
1347
+ * rotation state observable (§5). Revoking a service token also severs that
1348
+ * service's live socket — a server-side effect, merely reported here.
1349
+ */
1350
+ export async function token(ctx , cmd ) {
1351
+ // deps: mcpCall
1352
+ if (cmd.sub === "issue") {
1353
+ const minted = await adminOp(ctx, "token_issue", {
1354
+ kind: cmd.kind,
1355
+ slug: cmd.slug,
1356
+ ...(cmd.expires === undefined ? {} : { expires_in: cmd.expires }),
1357
+ });
1358
+ if (globals.json) return emitDocument(minted);
1359
+ write(`${String(minted.id)}\n${String(minted.token)}\n`);
1360
+ return 0;
1361
+ }
1362
+ if (cmd.sub === "revoke") {
1363
+ const id = await withIdPrefix(ctx, cmd.id, { op: "token_list", key: "tokens" }, async (resolved) => {
1364
+ await adminOp(ctx, "token_revoke", { id: resolved });
1365
+ return resolved;
1366
+ });
1367
+ if (globals.json) return emitDocument({ id, revoked: true });
1368
+ write(`revoked ${id}\n`);
1369
+ return 0;
1370
+ }
1371
+ const tokens = rows (await adminOp(ctx, "token_list"), "tokens");
1372
+ if (globals.json) return emitDocument({ tokens });
1373
+ const c = styling(decorated());
1374
+ const table = columnize(
1375
+ tokens.map((row) => [
1376
+ String(row.id),
1377
+ String(row.prefix ?? ""),
1378
+ row.expiresAt === null || row.expiresAt === undefined ? "never" : formatDate(Number(row.expiresAt)),
1379
+ row.lastUsedAt === null || row.lastUsedAt === undefined ? "never" : formatDateTime(Number(row.lastUsedAt)),
1380
+ ]),
1381
+ { headers: ["TOKEN", "PREFIX", "EXPIRES", "LAST USED"], tty: decorated() },
1382
+ ).split("\n");
1383
+ write(`${c.dim(table[0])}\n`);
1384
+ for (const line of table.slice(1)) write(`${line}\n`);
1385
+ return 0;
1386
+ }
1387
+
1388
+ /**
1389
+ * Filters for `pmcp audit`, mirroring audit_query's parameters (§8) with CLI
1390
+ * sugar main resolves before the call: `account` becomes principal
1391
+ * `"sa:<slug>"`; `since`/`until` arrive ALREADY RESOLVED by main's instantMs to
1392
+ * the epoch MS audit_query declares, so the human spellings it accepts (a "7d"
1393
+ * duration ago, an ISO instant, a bare epoch) never reach this type. `limit` is
1394
+ * the page (and export chunk) size, server default 100.
1395
+ */
1396
+
1397
+
1398
+
1399
+
1400
+
1401
+
1402
+
1403
+
1404
+
1405
+
1406
+
1407
+ /**
1408
+ * `pmcp audit` — audit_query presentation (§8). Default: one page as a table,
1409
+ * newest first, plus the "N of M events match" line from `total`; the timestamp column is
1410
+ * the wire's epoch-ms `ts` (§8's AuditRow) formatted locally. Recorded bodies (§15 —
1411
+ * post-redaction and stub-substituted, the only stored form) render in a row's detail, with
1412
+ * stubs shown as typed size placeholders (`‹blob image/png · 4.2 MB›`), never raw.
1413
+ * `exportJsonl` instead streams EVERY matching row to stdout, one JSON object per line —
1414
+ * bodies included verbatim as stored — by re-querying in limit-sized chunks, never held in
1415
+ * memory at once. Exit 0 even when nothing matches.
1416
+ */
1417
+ export async function audit(
1418
+ ctx ,
1419
+ filters ,
1420
+ opts ,
1421
+ ) {
1422
+ // deps: mcpCall
1423
+ const query = {
1424
+ ...(filters.account === undefined ? {} : { principal: `sa:${filters.account}` }),
1425
+ ...(filters.service === undefined ? {} : { service: filters.service }),
1426
+ ...(filters.event === undefined ? {} : { event: filters.event }),
1427
+ ...(filters.tool === undefined ? {} : { tool: filters.tool }),
1428
+ ...(filters.session === undefined ? {} : { session: filters.session }),
1429
+ ...(filters.since === undefined ? {} : { since: filters.since }),
1430
+ ...(filters.until === undefined ? {} : { until: filters.until }),
1431
+ ...(filters.limit === undefined ? {} : { limit: filters.limit }),
1432
+ };
1433
+ if (opts.exportJsonl !== true) {
1434
+ const page = await adminOp(ctx, "audit_query", query);
1435
+ const auditRows = (page.rows ?? []) ;
1436
+ if (globals.json) return emitDocument({ rows: auditRows, total: Number(page.total ?? 0) });
1437
+ if (auditRows.length > 0) {
1438
+ write(
1439
+ `${columnize(
1440
+ auditRows.map((row) => [
1441
+ // The wire sends epoch-ms `ts` (§8's AuditRow); the event vocabulary —
1442
+ // `tools/call`, `admin.<tool>`, `connect.register` — prints verbatim.
1443
+ formatDateTime(Number(row.ts ?? 0)),
1444
+ String(row.principal ?? ""),
1445
+ String(row.event ?? ""),
1446
+ `${String(row.service ?? "")}${row.tool === undefined ? "" : `/${String(row.tool)}`}`,
1447
+ `${String(row.outcome ?? "")}${renderBodies(row)}`,
1448
+ ]),
1449
+ { tty: decorated() },
1450
+ )}\n`,
1451
+ );
1452
+ }
1453
+ write(`${auditRows.length} of ${Number(page.total ?? 0)} events match\n`);
1454
+ return 0;
1455
+ }
1456
+ // The export re-queries in chunks and writes each as it arrives: the same rows as the
1457
+ // web export, never the whole result set in memory.
1458
+ const size = filters.limit ?? 100;
1459
+ for (let offset = 0; ; offset += size) {
1460
+ const page = await adminOp(ctx, "audit_query", { ...query, limit: size, offset });
1461
+ const chunk = (page.rows ?? []) ;
1462
+ for (const row of chunk) write(`${JSON.stringify(row)}\n`);
1463
+ if (chunk.length < size) return 0;
1464
+ }
1465
+ }
1466
+
1467
+ /** A recorded body's detail line — stubs as typed size placeholders, never raw bytes (§15). */
1468
+ function renderBodies(row ) {
1469
+ const parts = [];
1470
+ for (const key of ["args", "result"]) {
1471
+ const body = row[key];
1472
+ if (body === undefined || body === null) continue;
1473
+ parts.push(`${key}=${describeBody(body)}`);
1474
+ }
1475
+ return parts.length === 0 ? "" : ` ${parts.join(" ")}`;
1476
+ }
1477
+
1478
+ function describeBody(body ) {
1479
+ const stub = (body ).stub;
1480
+ if (stub === "blob") {
1481
+ const info = body ;
1482
+ return `‹blob ${String(info.contentType)} · ${formatBytes(info.bytes ?? 0)}›`;
1483
+ }
1484
+ if (stub === "oversize") return `‹oversize · ${formatBytes((body ).bytes ?? 0)}›`;
1485
+ return JSON.stringify(body);
1486
+ }
1487
+
1488
+ function formatBytes(bytes ) {
1489
+ return bytes >= 1_000_000 ? `${(bytes / 1_048_576).toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`;
1490
+ }
1491
+
1492
+ /** Epoch ms → `YYYY-MM-DD` / `YYYY-MM-DD HH:MM`, UTC, so a table column is fixed width. */
1493
+ function formatDate(ms ) {
1494
+ return new Date(ms).toISOString().slice(0, 10);
1495
+ }
1496
+
1497
+ function formatDateTime(ms ) {
1498
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
1499
+ }
1500
+
1501
+ /**
1502
+ * `pmcp diff -f mcps.yaml` — read the file, read the server (one service_list
1503
+ * plus one account_list), print the plan: creates/updates/deletes and
1504
+ * archive transitions with destructive steps flagged, then warnings, then
1505
+ * hard errors (§9). Exit 0 whenever the plan COMPUTES — empty or not, since drift
1506
+ * detection is `--json` + `steps.length` (§10) — and 1 when the file has hard errors.
1507
+ * Never mutates anything.
1508
+ */
1509
+ export async function diff(ctx , opts ) {
1510
+ // deps: yaml.parse · node:fs · plan.parseDesired · plan.planChanges · readCurrentState · renderPlan
1511
+ const plan = planChanges(desiredFrom(opts.file), await readCurrentState(ctx));
1512
+ if (globals.json) {
1513
+ emitDocument(plan);
1514
+ return plan.errors.length === 0 ? 0 : 1;
1515
+ }
1516
+ write(`${renderPlan(plan)}\n`);
1517
+ return plan.errors.length === 0 ? 0 : 1;
1518
+ }
1519
+
1520
+ /**
1521
+ * `pmcp apply -f mcps.yaml [--yes]` — shows exactly the plan diff prints,
1522
+ * refuses outright while it carries hard errors, asks for confirmation
1523
+ * (skipped by `--yes`), then executes the steps strictly in plan order, one
1524
+ * tool call each, stopping at the first failure and reporting the completed
1525
+ * prefix — steps are individual admin calls; there is no transaction to roll
1526
+ * back. `--json` carries the same steps with a per-step
1527
+ * `status: applied | skipped | failed` so CI never parses colored prose (§10).
1528
+ * Exit 0 only when every step succeeded.
1529
+ */
1530
+ export async function apply(ctx , opts ) {
1531
+ // deps: yaml.parse · node:fs · confirm · plan.* · readCurrentState · renderPlan · mcpCall
1532
+ const plan = planChanges(desiredFrom(opts.file), await readCurrentState(ctx));
1533
+ const outcomes = plan.steps.map((step) => ({ ...step, status: "skipped" , error: undefined }));
1534
+ if (!globals.json) write(`${renderPlan(plan)}\n`);
1535
+ if (plan.errors.length > 0) {
1536
+ if (globals.json) emitDocument({ steps: outcomes, warnings: plan.warnings, errors: plan.errors });
1537
+ return 1;
1538
+ }
1539
+ if (plan.steps.length === 0) {
1540
+ if (globals.json) emitDocument({ steps: outcomes, warnings: plan.warnings, errors: plan.errors });
1541
+ return 0;
1542
+ }
1543
+ if (!globals.yes && !(await confirm(`apply ${plan.steps.length} step(s)?`))) return 1;
1544
+ let failed = false;
1545
+ for (const outcome of outcomes) {
1546
+ if (failed) break;
1547
+ try {
1548
+ await adminOp(ctx, outcome.tool, outcome.args);
1549
+ outcome.status = "applied";
1550
+ if (!globals.json) write(` ok ${outcome.summary}\n`);
1551
+ } catch (error) {
1552
+ // No transaction to roll back: report the completed prefix and stop.
1553
+ outcome.status = "failed";
1554
+ outcome.error = error instanceof Error ? error.message : String(error);
1555
+ failed = true;
1556
+ if (!globals.json) write(` FAILED ${outcome.summary}: ${outcome.error}\n`);
1557
+ }
1558
+ }
1559
+ const applied = outcomes.filter((outcome) => outcome.status === "applied").length;
1560
+ if (globals.json) emitDocument({ steps: outcomes, warnings: plan.warnings, errors: plan.errors });
1561
+ else write(`${applied}/${plan.steps.length} steps applied\n`);
1562
+ return failed ? 1 : 0;
1563
+ }
1564
+
1565
+ /**
1566
+ * §9's file, through the `yaml` package (YAML 1.2 core schema): anchors, multi-line
1567
+ * scalars, flow mappings and multi-document files work; duplicate keys and tabs, which the
1568
+ * retired subset parser tolerated, are parse errors. A parse failure is the operator's
1569
+ * typo, not a runtime fault — it becomes a `usage` error naming the file.
1570
+ */
1571
+ function desiredFrom(file ) {
1572
+ try {
1573
+ return parseDesired(readYamlFile(file));
1574
+ } catch (error) {
1575
+ if (error instanceof CliError) throw error;
1576
+ // parseDesired's grammar refusals ("… is not a key of this grammar") are plain Errors,
1577
+ // and an uncaught one is labelled `remote_error` by errors.toCliError — telling an agent
1578
+ // the hub refused when the operator mistyped a key in their own file (§10's vocabulary).
1579
+ throw new CliError("usage", `${file}: ${error instanceof Error ? error.message : String(error)}`);
1580
+ }
1581
+ }
1582
+
1583
+ function readYamlFile(file ) {
1584
+ let text ;
1585
+ try {
1586
+ text = readFileSync(file, "utf8");
1587
+ } catch {
1588
+ throw new CliError("usage", `cannot read ${file}`, { hints: ["-f <file> names the YAML file (default mcps.yaml)"] });
1589
+ }
1590
+ try {
1591
+ return parseYaml(text);
1592
+ } catch (error) {
1593
+ throw new CliError("usage", `${file}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * `pmcp connect <service>` — prints the /services OAuth connect URL for an
1599
+ * `auth: oauth` proxied service (§7, §10). The consent redirect is inherently
1600
+ * a browser interaction (§8 pins Connect outside the tool surface), so
1601
+ * printing the URL is the whole command — the CLI never runs the flow. Checks
1602
+ * the slug via service_get first, so a typo or a headers-mode service fails
1603
+ * here, not in the browser.
1604
+ */
1605
+ export async function connect(ctx , service ) {
1606
+ // deps: mcpCall
1607
+ const row = ((await adminOp(ctx, "service_get", { slug: service })).service ?? {}) ;
1608
+ if (row.kind !== "proxy" || row.auth !== "oauth") {
1609
+ throw new CliError("invalid_arguments", `${service} is not an \`auth: oauth\` proxied service — nothing to connect`, {
1610
+ hints: ["pmcp ls lists your services"],
1611
+ });
1612
+ }
1613
+ const url = `${ctx.origin}/services?connect=${encodeURIComponent(service)}`;
1614
+ if (globals.json) return emitDocument({ service, connectUrl: url });
1615
+ write(`${url}\n`);
1616
+ return 0;
1617
+ }
1618
+
1619
+ /**
1620
+ * One connection command, normalized from `pmcp connection(s) …` argv (§10, §19). `list`
1621
+ * has no sub-argv of its own — `pmcp connections` is the whole command, mirroring `pmcp
1622
+ * ls` — while `revoke` takes the connection id `connections` prints.
1623
+ */
1624
+
1625
+
1626
+ /**
1627
+ * `pmcp connections | connection revoke <id>` — sugar over connection_list /
1628
+ * connection_revoke (§8/§19, §10): the OAuth clients (claude.ai and friends) connected to
1629
+ * this namespace via §19's inbound authorization server — a DISTINCT thing from `connect`'s
1630
+ * outbound upstream-OAuth URL above. `list` prints each live binding: the client's name (its
1631
+ * id, when it registered without one, §19.3), the service account it is bound to, and its
1632
+ * created/last-used timestamps — never a token, a client secret, or a JWT, because a
1633
+ * connection is a binding and a binding holds no credential (§8). `revoke` is immediate at
1634
+ * the door (§19.6): the connection's next call gets the 401 challenge, and the client's
1635
+ * consent is gone too, so a refresh cannot resurrect it silently.
1636
+ */
1637
+ export async function connection(ctx , cmd ) {
1638
+ // deps: mcpCall
1639
+ if (cmd.sub === "revoke") {
1640
+ const id = await withIdPrefix(ctx, cmd.id, { op: "connection_list", key: "connections" }, async (resolved) => {
1641
+ await adminOp(ctx, "connection_revoke", { id: resolved });
1642
+ return resolved;
1643
+ });
1644
+ if (globals.json) return emitDocument({ id, revoked: true });
1645
+ write(`revoked ${id}\n`);
1646
+ return 0;
1647
+ }
1648
+ const connections = rows (await adminOp(ctx, "connection_list"), "connections");
1649
+ if (globals.json) return emitDocument({ connections });
1650
+ const c = styling(decorated());
1651
+ const table = columnize(
1652
+ connections.map((row) => [
1653
+ String(row.id),
1654
+ String(row.clientName ?? row.clientId),
1655
+ String(row.accountSlug ?? ""),
1656
+ row.createdAt === null || row.createdAt === undefined ? "" : formatDateTime(Number(row.createdAt)),
1657
+ row.lastUsedAt === null || row.lastUsedAt === undefined ? "never" : formatDateTime(Number(row.lastUsedAt)),
1658
+ ]),
1659
+ { headers: ["CONNECTION", "CLIENT", "ACCOUNT", "CREATED", "LAST USED"], tty: decorated() },
1660
+ ).split("\n");
1661
+ write(`${c.dim(table[0])}\n`);
1662
+ for (const line of table.slice(1)) write(`${line}\n`);
1663
+ return 0;
1664
+ }
1665
+
1666
+ // ── the command table (§8 parity, direction D) ─────────────────────────────────────────
1667
+
1668
+ // The table itself lives in ./commands.ts — a module with no imports, so the parity suite
1669
+ // reads the same data this dispatcher uses without loading the CLI's filesystem access
1670
+ // into workerd. Re-exported so a CLI consumer still finds it where the surface lives.
1671
+ export { COMMANDS } from "./commands.mjs";
1672
+
1673
+
1674
+ // ── argv → one invocation ──────────────────────────────────────────────────────────────
1675
+
1676
+ /** §10's grouped overview, printed by `pmcp` and `pmcp help` before anything is resolved. */
1677
+ const OVERVIEW = `pmcp — personal MCP hub CLI
1678
+
1679
+ Explore
1680
+ ls services with kind, status, and your roles
1681
+ describe <ref> service/<slug>[/<item>] or account/<slug>
1682
+
1683
+ Invoke
1684
+ call <service> <tool> [key=value … | --args '{…}']
1685
+ get prompt/<service>/<name> [key=value … | --args '{…}']
1686
+ get resource/<service>/<uri>
1687
+
1688
+ Auth & profiles
1689
+ login [--profile <name>] [--url <origin>]
1690
+ logout · whoami
1691
+ profile add|list|use|remove
1692
+
1693
+ Admin
1694
+ service create|archive|unarchive|delete|disconnect|set-auth
1695
+ account list|create|delete
1696
+ approvals · approve <id> · reject <id>
1697
+ token issue|list|revoke
1698
+ connect <service> · connections · connection revoke <id>
1699
+ audit [--export jsonl]
1700
+
1701
+ Declarative
1702
+ diff [-f <file>] · apply [-f <file>] [--yes]
1703
+
1704
+ Global: --profile <name>, --json, --no-color, --yes, --version, -h
1705
+ `;
1706
+
1707
+ /**
1708
+ * Process entry: extract the global flags, answer help and `--version` BEFORE any context
1709
+ * resolution or network call (§10 — `pmcp tools --help` must never make a whoami first),
1710
+ * then let commander dispatch exactly one family invocation. Every failure lands in the one
1711
+ * catch below and is rendered by errors.emitError, which owns the grammar and the exit
1712
+ * code: `usage` is 2, every other code 1, and a plain thrown Error becomes `remote_error`.
1713
+ */
1714
+ export async function main(argv ) {
1715
+ // deps: commander · resolveContext · every exported command above · errors.emitError
1716
+ globals = extractGlobals(argv);
1717
+ pendingExit = 0;
1718
+ const words = globals.words;
1719
+ if (words.length === 0 || words[0] === "help" || words[0] === "--help" || words[0] === "-h") {
1720
+ write(OVERVIEW);
1721
+ return 0;
1722
+ }
1723
+ if (words[0] === "--version" || words[0] === "-V") {
1724
+ write(`${VERSION}\n`);
1725
+ return 0;
1726
+ }
1727
+ try {
1728
+ await buildProgram().parseAsync(words, { from: "user" });
1729
+ return pendingExit;
1730
+ } catch (error) {
1731
+ if (error instanceof CommanderError) {
1732
+ // `-h` on a subcommand has already printed its help: that is a success, not a failure.
1733
+ if (error.exitCode === 0) return 0;
1734
+ // A command family invoked with no subcommand (`pmcp token`) is commander asking for
1735
+ // help, which it writes to stderr with a non-zero status. §10 says help is help: it
1736
+ // goes to stdout and exits 0, like every other spelling of `-h`.
1737
+ if (error.code === "commander.help") {
1738
+ write(commanderOut);
1739
+ return 0;
1740
+ }
1741
+ const [message, ...detail] = commanderLines(error);
1742
+ return emitError(new CliError("usage", message, { detail, hints: ["pmcp help"] }), {
1743
+ json: globals.json,
1744
+ stream: process.stderr,
1745
+ });
1746
+ }
1747
+ return emitError(hubErrorToCliError(error), { json: globals.json, stream: process.stderr });
1748
+ }
1749
+ }
1750
+
1751
+ /**
1752
+ * commander's own prose, stripped of the `error: ` prefix emitError puts back with a code,
1753
+ * and split so its second line (`(Did you mean …?)`) becomes INDENTED detail rather than a
1754
+ * column-0 line an agent would have to recognize — §8's grammar reserves column 0 for
1755
+ * `error:`/`usage:`/`hint:` and nothing else.
1756
+ */
1757
+ function commanderLines(error ) {
1758
+ const [first, ...rest] = error.message.replace(/^error:\s*/, "").split("\n");
1759
+ return [first, ...rest.filter((line) => line.trim() !== "").map((line) => line.trim())];
1760
+ }
1761
+
1762
+ /**
1763
+ * A hub refusal that reached the top without command-specific enrichment, mapped onto §10's
1764
+ * frozen code vocabulary. Anything else — a CliError, a network exception — passes straight
1765
+ * through to emitError, which normalizes what it does not recognize.
1766
+ */
1767
+ function hubErrorToCliError(error ) {
1768
+ if (!(error instanceof HubRpcError)) return error;
1769
+ if (error.code === HUB_ERRORS.approvalRequired) {
1770
+ const data = error.data ;
1771
+ return new CliError("approval_required", `approval required (${data.approvalId})`, {
1772
+ detail: [`approve at ${data.approvalUrl}`, `then re-run this exact call before ${data.expiresAt}`],
1773
+ });
1774
+ }
1775
+ if (error.code === HUB_ERRORS.toolNotPermitted) return new CliError("not_found", error.message);
1776
+ if (error.code === HUB_ERRORS.invalidParams) return new CliError("invalid_arguments", error.message);
1777
+ return new CliError("remote_error", error.message);
1778
+ }
1779
+
1780
+ /**
1781
+ * The commander 15 program. Every subcommand is registered here — the 32 legacy spellings
1782
+ * (five of them hidden: `tools`, `prompts`, `resources`, `prompt`, `read`, whose documented
1783
+ * surface is now `describe`/`get` but whose COMMANDS rows the frozen parity suite still
1784
+ * asserts) plus §10's new `profile`, `describe` and `get`, and the guessable noun-verb
1785
+ * aliases `service list` / `connection list` / `approval list`.
1786
+ *
1787
+ * The global flags are NOT declared here: they are positional-free (`pmcp service --yes
1788
+ * delete news` and `pmcp service delete news --yes` are one command) and were consumed from
1789
+ * argv before commander saw it.
1790
+ */
1791
+ /** Appended to every subcommand's `-h`: the flags stripped from argv before commander parses. */
1792
+ const GLOBAL_HELP = "\nGlobal: --profile <name>, --json, --no-color, --yes";
1793
+
1794
+ function buildProgram() {
1795
+ const program = new Command();
1796
+ program.name("pmcp").version(VERSION).exitOverride();
1797
+ // commander's own stderr prose would duplicate emitError's grammar; the exception it
1798
+ // throws carries the same text, and main renders it with a code and an exit status.
1799
+ commanderOut = "";
1800
+ program.configureOutput({
1801
+ writeErr: (text) => {
1802
+ commanderOut += text;
1803
+ },
1804
+ });
1805
+
1806
+ /**
1807
+ * One subcommand. `example` becomes the "usage + one example" §10 asks every `-h` to
1808
+ * answer with, and is spelled out only where the argv shape is not its own example —
1809
+ * `pmcp logout` needs no illustration; `pmcp get resource/notes/file:///todo.md` does.
1810
+ */
1811
+ const on = (name , describe_ , example , hidden = false) => {
1812
+ const command = program.command(name, { hidden }).description(describe_);
1813
+ const withExample = example === undefined ? command : command.addHelpText("after", `\nExample:\n ${example}`);
1814
+ // The global flags are stripped before commander parses (they are positional-free by
1815
+ // contract), so nothing declares them per command — this footer is how `pmcp <cmd> -h`
1816
+ // still teaches them (§10: agents learn from help text, not pickers).
1817
+ return withExample.addHelpText("after", GLOBAL_HELP);
1818
+ };
1819
+
1820
+ on("login", "log in to a hub via the RFC 8628 device flow", "pmcp login --profile work --url https://hub.example.com")
1821
+ .option("--url <origin>", "the hub's https origin")
1822
+ .action(async (opts ) => {
1823
+ pendingExit = (await login(opts.url)) ;
1824
+ });
1825
+ on("logout", "clear the active profile's token").action(async () => {
1826
+ pendingExit = (await logout()) ;
1827
+ });
1828
+ on("whoami", "print the principal and namespace this token resolves to").action(async () => {
1829
+ pendingExit = (await whoami()) ;
1830
+ });
1831
+
1832
+ const profiles = on("profile", "manage named hub identities");
1833
+ profiles
1834
+ .command("add <name>")
1835
+ .option("--url <origin>", "the hub's https origin")
1836
+ .description("record a hub url under a name (login fills the token)")
1837
+ .action(async (name , opts ) => {
1838
+ // §10's interactivity sentence names two homes for prompts — `login` and `profile
1839
+ // add` — and this is the latter's half: ask for the one missing piece on a TTY,
1840
+ // refuse as argv anywhere a prompt has nobody to answer it.
1841
+ let url = opts.url;
1842
+ if (url === undefined && process.stdin.isTTY === true && !globals.json) url = (await askForUrl()).replace(/\/+$/, "");
1843
+ if (url === undefined) {
1844
+ throw new CliError("usage", "missing --url", { usage: "pmcp profile add <name> --url <origin>" });
1845
+ }
1846
+ pendingExit = (await profile({ sub: "add", name, url })) ;
1847
+ });
1848
+ profiles
1849
+ .command("list")
1850
+ .description("every profile, with the active one marked")
1851
+ .action(async () => {
1852
+ pendingExit = (await profile({ sub: "list" })) ;
1853
+ });
1854
+ profiles
1855
+ .command("use <name>")
1856
+ .description("set the file's default profile")
1857
+ .action(async (name ) => {
1858
+ pendingExit = (await profile({ sub: "use", name })) ;
1859
+ });
1860
+ profiles
1861
+ .command("remove <name>")
1862
+ .description("delete a profile (the active one needs --yes)")
1863
+ .action(async (name ) => {
1864
+ pendingExit = (await profile({ sub: "remove", name })) ;
1865
+ });
1866
+ // The profile subcommands are registered off `profiles`, not through `on`, so they get
1867
+ // the global-flags footer here.
1868
+ for (const sub of profiles.commands) sub.addHelpText("after", GLOBAL_HELP);
1869
+
1870
+ on("ls", "services with kind, status, and your roles", "pmcp ls --json").action(async () => {
1871
+ pendingExit = (await ls(await context())) ;
1872
+ });
1873
+
1874
+ on("describe [ref]", "service/<slug>[/<item>] or account/<slug>", "pmcp describe service/mcp-tools/paper_fetch").action(async (ref ) => {
1875
+ if (ref === undefined) {
1876
+ throw new CliError("usage", "missing ref", {
1877
+ usage: "pmcp describe <service/<slug>[/<item>] | account/<slug>>",
1878
+ hints: ["pmcp ls lists your services"],
1879
+ });
1880
+ }
1881
+ // Parsed BEFORE the context, as in `get`/`call`: argument-list evaluation order would
1882
+ // otherwise report a malformed ref as whatever the hub said about the token (§10's
1883
+ // local-first rule) — and `describe news` with the ref type left off is the likeliest
1884
+ // typo on this verb.
1885
+ const parsed = parseRef(ref, ["service", "account"], "describe");
1886
+ pendingExit = (await describe(await context(), parsed)) ;
1887
+ });
1888
+
1889
+ on("get [ref] [args...]", "prompt/<service>/<name> or resource/<service>/<uri>", "pmcp get resource/notes/file:///todo.md")
1890
+ .option("--args <json>", "the arguments object, as JSON")
1891
+ .action(async (ref , words , opts ) => {
1892
+ if (ref === undefined) {
1893
+ throw new CliError("usage", "missing ref", {
1894
+ usage: "pmcp get <prompt/<service>/<name> | resource/<service>/<uri>>",
1895
+ hints: ["pmcp describe service/<slug> lists what a service serves"],
1896
+ });
1897
+ }
1898
+ const parsed = parseRef(ref, ["prompt", "resource"], "get");
1899
+ if (parsed.item === undefined) {
1900
+ throw new CliError("usage", `ref "${ref}" names no ${parsed.kind}`, {
1901
+ usage: `pmcp get ${parsed.kind}/<service>/<${parsed.kind === "prompt" ? "name" : "uri"}>`,
1902
+ });
1903
+ }
1904
+ if (parsed.kind === "resource") {
1905
+ // A trailing word here would be silently dropped — the call executed would differ
1906
+ // from the one typed, which is worse than refusing.
1907
+ if (words.length > 0 || opts.args !== undefined) {
1908
+ throw new CliError("usage", "resources/read takes no arguments", {
1909
+ usage: "pmcp get resource/<service>/<uri>",
1910
+ });
1911
+ }
1912
+ pendingExit = (await read(await context(), parsed.slug, parsed.item)) ;
1913
+ return;
1914
+ }
1915
+ // Parsed BEFORE the context: `await context()` is a network whoami, and argument-list
1916
+ // evaluation order would otherwise report malformed argv as whatever the hub said
1917
+ // about the token (§10 — pure argv mistakes are caught locally, before any network).
1918
+ const args = toolArguments(words, opts.args);
1919
+ pendingExit = (await prompt(await context(), parsed.slug, parsed.item, args)) ;
1920
+ });
1921
+
1922
+ on("call [words...]", "call a tool: <service> <tool> or <slug>_<tool>, plus key=value args", "pmcp call mcp-tools paper_fetch url=https://arxiv.org/abs/2408.00001")
1923
+ .option("--args <json>", "the arguments object, as JSON")
1924
+ .action(async (words , opts ) => {
1925
+ // Partitioned by SHAPE, never by count: a word carrying `=` is an argument wherever
1926
+ // it sits, so `pmcp call news_echo text=hi` is the aggregated name plus an argument
1927
+ // and not a service called `news_echo` with a tool called `text=hi`.
1928
+ const positionals = words.filter((word) => !word.includes("="));
1929
+ if (positionals.length > 2) {
1930
+ throw new CliError("usage", `"${positionals[2]}" is neither a service, a tool, nor key=value`, {
1931
+ usage: "pmcp call <service> <tool> [key=value … | --args '{…}']",
1932
+ });
1933
+ }
1934
+ const target = requireWord(positionals[0], "service", "pmcp call <service> <tool> [key=value … | --args '{…}']");
1935
+ const split = positionals.length > 1 ? { service: target, tool: positionals[1] } : splitAggregated(target);
1936
+ // Before the context, deliberately: `await context()` is a network whoami, and an
1937
+ // argument list evaluates left to right — a malformed `--args` resolved after it would
1938
+ // be reported as a hub failure on an unreachable hub (§10's local-first rule).
1939
+ const args = toolArguments(words.filter((word) => word.includes("=")), opts.args);
1940
+ pendingExit = (await call(await context(), split, args)) ;
1941
+ });
1942
+
1943
+ on("tools <service>", "list a service's tools", undefined, true).action(async (svc ) => {
1944
+ pendingExit = (await tools(await context(), svc)) ;
1945
+ });
1946
+ on("prompts <service>", "list a service's prompts", undefined, true).action(async (svc ) => {
1947
+ pendingExit = (await prompts(await context(), svc)) ;
1948
+ });
1949
+ on("prompt [words...]", "get one prompt", undefined, true)
1950
+ .option("--args <json>", "the arguments object, as JSON")
1951
+ .action(async (words , opts ) => {
1952
+ const positionals = words.filter((word) => !word.includes("="));
1953
+ // Whole argv check before the context, as in `get`/`call`: `await context()` is a
1954
+ // network whoami, and an argument list evaluates left to right (§10's local-first rule).
1955
+ const svc = requireWord(positionals[0], "service", "pmcp prompt <service> <name> [key=value …]");
1956
+ const name = requireWord(positionals[1], "prompt name", "pmcp prompt <service> <name> [key=value …]");
1957
+ const args = toolArguments(words.filter((word) => word.includes("=")), opts.args);
1958
+ pendingExit = (await prompt(await context(), svc, name, args)) ;
1959
+ });
1960
+ on("resources <service>", "list a service's resources", undefined, true)
1961
+ .option("--templates", "list resource templates instead")
1962
+ .action(async (svc , opts ) => {
1963
+ pendingExit = (await resources(await context(), svc, { templates: opts.templates })) ;
1964
+ });
1965
+ on("read [words...]", "read one resource", undefined, true).action(async (words ) => {
1966
+ if (words.length >= 2) {
1967
+ pendingExit = (await read(await context(), words[0], words[1])) ;
1968
+ return;
1969
+ }
1970
+ // §20.2: resources have no aggregated endpoint, so a lone word here is ambiguous only
1971
+ // in FORM, never in meaning. One that looks like a URI means the service was left out —
1972
+ // this would have addressed the aggregate, which §20.2 refuses, and the refusal reason
1973
+ // travels with it so the operator is not sent looking for a slug that does not exist.
1974
+ // Anything else means the uri was left out, an ordinary usage error.
1975
+ if (words.length === 1 && words[0].includes("://")) {
1976
+ throw new CliError(
1977
+ "usage",
1978
+ "pmcp read needs a <service> before the uri — resources are scoped-only, there is no aggregated endpoint for them (§20.2)",
1979
+ { usage: "pmcp read <service> <uri>" },
1980
+ );
1981
+ }
1982
+ throw new CliError("usage", "missing uri", { usage: "pmcp read <service> <uri>" });
1983
+ });
1984
+
1985
+ const services = on("service", "create and manage services");
1986
+ services
1987
+ .command("create <slug>")
1988
+ .description("create a tunneled or proxied service")
1989
+ .option("--tunneled", "a tunneled service (the default)")
1990
+ .option("--proxied <endpoint>", "a proxied service at this endpoint")
1991
+ .option("--auth <mode>", "headers | oauth (proxied only)")
1992
+ .action(async (slug , opts ) => {
1993
+ const cmd =
1994
+ opts.proxied === undefined
1995
+ ? { sub: "create", slug, kind: "tunnel" }
1996
+ : { sub: "create", slug, kind: "proxy", endpoint: opts.proxied, auth: opts.auth === "oauth" ? "oauth" : "headers" };
1997
+ pendingExit = (await service(await context(), cmd)) ;
1998
+ });
1999
+ for (const sub of ["archive", "unarchive", "delete", "disconnect"] ) {
2000
+ services
2001
+ .command(`${sub} <slug>`)
2002
+ .description(`${sub} a service`)
2003
+ .action(async (slug ) => {
2004
+ pendingExit = (await service(await context(), { sub, slug })) ;
2005
+ });
2006
+ }
2007
+ services
2008
+ .command("set-auth <slug>")
2009
+ .description("replace a proxied service's upstream headers")
2010
+ .option("--header <header...>", "'Name: value', repeatable")
2011
+ .action(async (slug , opts ) => {
2012
+ const headers = {};
2013
+ for (const header of opts.header ?? []) {
2014
+ const colon = header.indexOf(":");
2015
+ if (colon === -1) throw new CliError("usage", `--header wants 'Name: value', got ${header}`);
2016
+ headers[header.slice(0, colon).trim()] = header.slice(colon + 1).trim();
2017
+ }
2018
+ pendingExit = (await service(await context(), { sub: "set-auth", slug, headers })) ;
2019
+ });
2020
+ // The guessable noun-verb form (§10): `pmcp service list` is `pmcp ls`.
2021
+ services
2022
+ .command("list")
2023
+ .description("alias of `pmcp ls`")
2024
+ .action(async () => {
2025
+ pendingExit = (await ls(await context())) ;
2026
+ });
2027
+
2028
+ const accounts = on("account", "service accounts and their grants");
2029
+ accounts
2030
+ .command("list")
2031
+ .description("every service account with its grants inline")
2032
+ .action(async () => {
2033
+ pendingExit = (await account(await context(), { sub: "list" })) ;
2034
+ });
2035
+ accounts
2036
+ .command("create <slug>")
2037
+ .description("create a service account")
2038
+ .option("--name <name>", "display name")
2039
+ .option("--description <text>", "description")
2040
+ .action(async (slug , opts ) => {
2041
+ pendingExit = (await account(await context(), { sub: "create", slug, name: opts.name, description: opts.description })) ;
2042
+ });
2043
+ accounts
2044
+ .command("delete <slug>")
2045
+ .description("delete a service account (grants cascade)")
2046
+ .action(async (slug ) => {
2047
+ pendingExit = (await account(await context(), { sub: "delete", slug })) ;
2048
+ });
2049
+
2050
+ on("approvals", "pending approval requests, newest first", "pmcp approvals --pending --json")
2051
+ .option("--pending", "pending only")
2052
+ .option("--history", "decided only")
2053
+ .action(async (opts ) => {
2054
+ pendingExit = (await approval(await context(), {
2055
+ sub: "list",
2056
+ filter: opts.pending === true ? "pending" : opts.history === true ? "history" : undefined,
2057
+ })) ;
2058
+ });
2059
+ for (const decision of ["approve", "reject"] ) {
2060
+ on(`${decision} <id>`, `${decision} one pending request`).action(async (id ) => {
2061
+ pendingExit = (await approval(await context(), { sub: decision, id })) ;
2062
+ });
2063
+ }
2064
+ // `pmcp approval list` → `pmcp approvals` (§10's noun-verb alias).
2065
+ on("approval", "alias family: `pmcp approval list` is `pmcp approvals`", undefined, true)
2066
+ .command("list")
2067
+ .action(async () => {
2068
+ pendingExit = (await approval(await context(), { sub: "list" })) ;
2069
+ });
2070
+
2071
+ const tokens = on("token", "issue, list and revoke credentials");
2072
+ tokens
2073
+ .command("issue")
2074
+ .description("mint a key for a service account or a tunneled service")
2075
+ .option("--account <slug>", "a service account")
2076
+ .option("--service <slug>", "a tunneled service")
2077
+ .option("--expires <duration>", "90d | 3600 | never")
2078
+ .action(async (opts ) => {
2079
+ // Resolved before either branch, so an untranslatable lifetime fails the same way for
2080
+ // both kinds — and before anything is minted.
2081
+ const expires = expiresIn(opts.expires);
2082
+ const cmd =
2083
+ opts.account !== undefined
2084
+ ? { sub: "issue", kind: "service_account", slug: opts.account, expires }
2085
+ : opts.service !== undefined
2086
+ ? { sub: "issue", kind: "service", slug: opts.service, expires }
2087
+ : (() => {
2088
+ throw new CliError("usage", "pmcp token issue needs --account <slug> or --service <slug>", {
2089
+ usage: "pmcp token issue (--account <slug> | --service <slug>) [--expires 90d]",
2090
+ });
2091
+ })();
2092
+ pendingExit = (await token(await context(), cmd)) ;
2093
+ });
2094
+ tokens
2095
+ .command("list")
2096
+ .description("this namespace's credentials, never plaintext")
2097
+ .action(async () => {
2098
+ pendingExit = (await token(await context(), { sub: "list" })) ;
2099
+ });
2100
+ tokens
2101
+ .command("revoke <id>")
2102
+ .description("revoke one credential, immediately")
2103
+ .action(async (id ) => {
2104
+ pendingExit = (await token(await context(), { sub: "revoke", id })) ;
2105
+ });
2106
+
2107
+ on("audit", "the namespace's event history", "pmcp audit --service mcp-tools --since 7d")
2108
+ .option("--account <slug>", "narrow to one service account")
2109
+ .option("--service <slug>", "narrow to one service")
2110
+ .option("--event <name>", "exact event name")
2111
+ .option("--tool <name>", "exact unprefixed tool name")
2112
+ .option("--session <id>", "exact client session id")
2113
+ .option("--since <when>", "7d | ISO-8601 | epoch ms")
2114
+ .option("--until <when>", "7d | ISO-8601 | epoch ms")
2115
+ .option("--limit <count>", "page size")
2116
+ .option("--export <format>", "jsonl streams every matching row")
2117
+ .action(async (opts ) => {
2118
+ // The two translated flags resolve BEFORE the context: `await context()` is a network
2119
+ // whoami, and an unparseable `--since` resolved after it would be reported as a hub
2120
+ // failure rather than the malformed argv it is (§10).
2121
+ const filters = {
2122
+ account: opts.account,
2123
+ service: opts.service,
2124
+ event: opts.event,
2125
+ tool: opts.tool,
2126
+ session: opts.session,
2127
+ since: instantMs("since", opts.since),
2128
+ until: instantMs("until", opts.until),
2129
+ limit: opts.limit === undefined ? undefined : Number(opts.limit),
2130
+ };
2131
+ pendingExit = (await audit(await context(), filters, { exportJsonl: opts.export === "jsonl" })) ;
2132
+ });
2133
+
2134
+ on("connect <service>", "print a proxied service's OAuth connect URL", "pmcp connect linear").action(async (svc ) => {
2135
+ pendingExit = (await connect(await context(), svc)) ;
2136
+ });
2137
+ on("connections", "the OAuth clients connected to this namespace").action(async () => {
2138
+ pendingExit = (await connection(await context(), { sub: "list" })) ;
2139
+ });
2140
+ const connections = on("connection", "inbound OAuth client connections");
2141
+ connections
2142
+ .command("revoke <id>")
2143
+ .description("revoke one connection, immediately")
2144
+ .action(async (id ) => {
2145
+ pendingExit = (await connection(await context(), { sub: "revoke", id })) ;
2146
+ });
2147
+ connections
2148
+ .command("list")
2149
+ .description("alias of `pmcp connections`")
2150
+ .action(async () => {
2151
+ pendingExit = (await connection(await context(), { sub: "list" })) ;
2152
+ });
2153
+
2154
+ on("diff", "plan the changes a YAML file would make", "pmcp diff -f mcps.yaml --json")
2155
+ .option("-f, --file <file>", "the YAML file", "mcps.yaml")
2156
+ .action(async (opts ) => {
2157
+ pendingExit = (await diff(await context(), { file: opts.file })) ;
2158
+ });
2159
+ on("apply", "apply the plan a YAML file describes", "pmcp apply -f mcps.yaml --yes")
2160
+ .option("-f, --file <file>", "the YAML file", "mcps.yaml")
2161
+ .action(async (opts ) => {
2162
+ pendingExit = (await apply(await context(), { file: opts.file })) ;
2163
+ });
2164
+
2165
+ return program;
2166
+ }
2167
+
2168
+ function requireWord(value , what , usage ) {
2169
+ if (value === undefined || value === "") throw new CliError("usage", `missing ${what}`, { usage });
2170
+ return value;
2171
+ }
2172
+
2173
+ /**
2174
+ * The CLI's one duration grammar — `<count><unit>` over seconds, minutes, hours, days
2175
+ * (§10 spells `--since 7d` and `--expires 90d` with it) — as a span in milliseconds, or
2176
+ * undefined for a value that is not one. Deliberately unopinionated about the miss: the
2177
+ * two flags below differ in what ELSE they accept and in what unit they must end up, and
2178
+ * only they know that.
2179
+ */
2180
+ const DURATION = /^(\d+)([smhd])$/;
2181
+ const DURATION_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } ;
2182
+
2183
+ function durationMs(value ) {
2184
+ // deps: none
2185
+ const match = DURATION.exec(value);
2186
+ return match === null ? undefined : Number(match[1]) * DURATION_UNIT_MS[match[2] ];
2187
+ }
2188
+
2189
+ /**
2190
+ * `--since` / `--until` → the epoch-MS integer audit_query declares (§8). Resolved HERE
2191
+ * and not forwarded, because the hub's field is an integer and it has no duration grammar
2192
+ * to resolve one with: a duration is that long AGO, an all-digits value is the epoch it
2193
+ * already spells, and anything else is read as an ISO-8601 instant. A value that is none
2194
+ * of the three is malformed argv naming the flag — a frame the hub would answer with
2195
+ * `invalid params` is one this CLI must never put on the wire.
2196
+ */
2197
+ function instantMs(flag , value ) {
2198
+ // deps: none
2199
+ if (value === undefined) return undefined;
2200
+ const ago = durationMs(value);
2201
+ if (ago !== undefined) return Date.now() - ago;
2202
+ if (/^\d+$/.test(value)) return Number(value);
2203
+ const instant = Date.parse(value);
2204
+ if (Number.isNaN(instant)) {
2205
+ throw new CliError("usage", `--${flag} wants a duration ago (7d, 12h, 30m, 45s), an ISO-8601 instant, or epoch ms — got "${value}"`);
2206
+ }
2207
+ return instant;
2208
+ }
2209
+
2210
+ /**
2211
+ * `--expires` → the SECONDS-of-lifetime integer token_issue declares, or the literal
2212
+ * `never` (§8). A duration here is a LIFETIME from now rather than an instant, and the
2213
+ * unit is seconds where audit's is milliseconds — which is exactly why the two flags
2214
+ * share the grammar above and nothing else. An all-digits value is the second count it
2215
+ * already spells; anything else is the same local refusal, raised before a key is minted.
2216
+ */
2217
+ function expiresIn(value ) {
2218
+ // deps: none
2219
+ if (value === undefined || value === "never") return value;
2220
+ const lifetime = durationMs(value);
2221
+ if (lifetime !== undefined) return lifetime / 1_000;
2222
+ if (/^\d+$/.test(value)) return Number(value);
2223
+ throw new CliError("usage", `--expires wants a duration (90d, 12h, 30m, 45s), a count of seconds, or "never" — got "${value}"`);
2224
+ }
2225
+
2226
+ /** `<slug>_<tool>` → its two halves; the first underscore is the split (§7). */
2227
+ function splitAggregated(target ) {
2228
+ const underscore = target.indexOf("_");
2229
+ if (underscore === -1) {
2230
+ throw new CliError("usage", `"${target}" is not <service> <tool> or <slug>_<tool>`, {
2231
+ usage: "pmcp call <service> <tool> [key=value … | --args '{…}']",
2232
+ hints: [`pmcp describe service/${target} lists its tools`],
2233
+ });
2234
+ }
2235
+ return { service: target.slice(0, underscore), tool: target.slice(underscore + 1) };
2236
+ }
2237
+
2238
+ /**
2239
+ * `--args '{…}'`, or repeated `key=value` words, into one arguments object. `--args` is the
2240
+ * payload flag §10 renamed it to: `--json` now means output format on every command, and
2241
+ * the two could not both be spelled `--json`.
2242
+ */
2243
+ function toolArguments(words , argsJson ) {
2244
+ // Both spellings at once would make one of them silently lose — the executed call would
2245
+ // differ from the one typed, which is worse than refusing.
2246
+ if (argsJson !== undefined && words.length > 0) {
2247
+ throw new CliError("usage", "--args and key=value are two spellings of the same arguments object — pick one");
2248
+ }
2249
+ if (argsJson !== undefined) {
2250
+ try {
2251
+ return JSON.parse(argsJson) ;
2252
+ } catch (error) {
2253
+ throw new CliError("usage", `--args is not valid JSON (${error instanceof Error ? error.message : String(error)})`, {
2254
+ hints: [`quote the keys: --args '{"url":"…"}'`],
2255
+ });
2256
+ }
2257
+ }
2258
+ const args = {};
2259
+ for (const word of words) {
2260
+ const equals = word.indexOf("=");
2261
+ // The caller partitions by shape, so this is a guard rather than a filter: a word the
2262
+ // user typed and this function dropped would be a silently empty argument object.
2263
+ if (equals === -1) throw new CliError("usage", `expected key=value, got ${word}`);
2264
+ args[word.slice(0, equals)] = word.slice(equals + 1);
2265
+ }
2266
+ return args;
2267
+ }
2268
+
2269
+ /**
2270
+ * One y/N prompt on stdin; anything but y/yes is a refusal — and so is having nobody to
2271
+ * ask. A non-interactive stdin (CI, cron, `pmcp apply < /dev/null`) refuses immediately
2272
+ * ON STDERR instead of waiting for a `data` event that can never come: a destructive
2273
+ * command that silently applied nothing and exited 0 is the worst failure `apply` has.
2274
+ */
2275
+ function confirm(question ) {
2276
+ if (process.stdin.isTTY !== true) {
2277
+ emitError(
2278
+ new CliError("confirmation_required", question, { hints: ["pass --yes to confirm without a terminal"] }),
2279
+ { json: globals.json, stream: process.stderr },
2280
+ );
2281
+ return Promise.resolve(false);
2282
+ }
2283
+ // The question goes to STDERR: the answer comes from stdin either way, and stdout belongs
2284
+ // to the command's output alone — `pmcp apply --json` without `--yes` would otherwise put
2285
+ // human chatter in front of the document §10 promises is the only thing there.
2286
+ process.stderr.write(`${question} [y/N] `);
2287
+ return new Promise ((resolve) => {
2288
+ process.stdin.setEncoding("utf8");
2289
+ process.stdin.resume();
2290
+ process.stdin.once("data", (chunk ) => {
2291
+ process.stdin.pause();
2292
+ resolve(/^y(es)?$/i.test(String(chunk).trim()));
2293
+ });
2294
+ // Closed mid-prompt is the same answer as "no", and a defined one.
2295
+ process.stdin.once("end", () => resolve(false));
2296
+ });
2297
+ }
2298
+
2299
+ /** Kept exported for the `pnpm users` bridge — the precedence lives in config.ts now. */
2300
+ export { applyProfile } from "./config.mjs";