@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.
- package/dist/pmcp.mjs +14 -0
- package/dist/src/commands.mjs +98 -0
- package/dist/src/config.mjs +197 -0
- package/dist/src/errors.mjs +173 -0
- package/dist/src/main.mjs +2300 -0
- package/dist/src/plan.mjs +828 -0
- package/dist/src/render.mjs +215 -0
- package/package.json +34 -0
|
@@ -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";
|