@chrischall/mcp-host-cli 0.0.0-stage → 2.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ // Every operation in the control API's contract (packages/core/openapi/
2
+ // openapi.json), and how this CLI reaches it.
3
+ //
4
+ // This table IS the parity claim, and `tests/api-parity.test.ts` holds it to
5
+ // the contract in both directions: every operation the document declares is
6
+ // either a row here or an EXCLUDED entry with its reason, nothing is listed
7
+ // that the document does not declare, and every row is DRIVEN — the test runs
8
+ // the CLI with the row's example against a listener and requires the request
9
+ // to arrive. So a new route in `core/src/openapi.ts` fails this package's
10
+ // suite until somebody decides how the CLI reaches it, which is the step that
11
+ // was missing while the portal grew three screens of API the CLI never
12
+ // learned (the CLI was written in #1, beside six routes; the portal is where
13
+ // every later route was built, and nothing asked the CLI to follow).
14
+ //
15
+ // Two kinds of row. A GENERIC row is data — words, positionals, query flags,
16
+ // body flags — and `runGeneric` in index.mjs executes it with no code of its
17
+ // own: positionals named in the path fill the path, the rest become body
18
+ // fields, `--<kebab-name>` flags become query parameters or typed body fields,
19
+ // and any row with a body also takes `--data <json | @file | ->` for the
20
+ // fields no flag names. The test additionally holds a generic row's query
21
+ // flags EQUAL to the operation's query parameters, and its body flags to
22
+ // properties the body schema declares — so a parameter added to the API is a
23
+ // failure here, not a silently unreachable option. A BESPOKE row names a
24
+ // command that `main()` in index.mjs implements by hand, because it does
25
+ // something a request cannot (seals a secret, uploads a file, prompts, renders
26
+ // a table); its example is still driven.
27
+
28
+ /** Flags every paged list endpoint takes (core/src/list-query.ts). */
29
+ const LIST = ['sort', 'order', 'q', 'page', 'perPage'];
30
+ /** The same, under a prefix, for an endpoint carrying two lists. */
31
+ const prefixed = (prefix) => LIST.map((name) => `${prefix}${name[0].toUpperCase()}${name.slice(1)}`);
32
+
33
+ /**
34
+ * Operations the CLI deliberately does not reach, each with the reason. A
35
+ * reason is a statement about WHO makes the call, never "not done yet": an
36
+ * operation nobody has decided about belongs in OPERATIONS, and the test's
37
+ * job is to make that decision unavoidable.
38
+ */
39
+ export const EXCLUDED = {
40
+ 'GET /admin/session': 'the portal\'s cookie session; the CLI authenticates with a bearer on every request',
41
+ 'POST /admin/session': 'the portal\'s cookie sign-in; the CLI authenticates with a bearer on every request',
42
+ 'DELETE /admin/session': 'the portal\'s cookie sign-out; a bearer has no session to end',
43
+ 'GET /m/marketplace.json': 'the feed Claude Code reads with the marketplace token; `marketplace rotate` prints the command that subscribes it',
44
+ 'GET /m/{token}/marketplace.json': 'the deprecated token-in-path form of the same feed, read by Claude Code',
45
+ 'POST /bridge/connect/start': 'the browser extension starting a Connect with its own keys (D18); the CLI holds no extension identity, and since D18 no CLI, operator or ADMIN_TOKEN mints a bridge credential',
46
+ 'POST /bridge/connect/approve': 'the connect page\'s Confirm, in the account owner\'s own signed-in session with CSRF; a CLI bearer (ADMIN_TOKEN) has nobody behind it and is refused by design',
47
+ 'POST /bridge/connect/finish': 'the browser extension finishing a Connect, signed by the key that started it (D18); the credential goes to that extension alone',
48
+ 'GET /bridge/connect/status': 'the connect page polling its own request, in the approving owner\'s session; the CLI holds no session',
49
+ 'POST /api/v1/bridge-tokens/{id}/serve': 'choosing which of the owner\'s browsers serves the account, for the account owner\'s own session only (the portal and the app, spec 2026-10-05 §5.7); a CLI bearer (ADMIN_TOKEN) gets the cross-account 404 by design',
50
+ 'GET /api/v1/bridge-tokens/presence': 'whether the owner\'s browser is attached right now, for the account owner\'s own session only (portal or app, spec §4.13); a CLI bearer (ADMIN_TOKEN) gets the cross-account 404 by design',
51
+ 'PUT /api/v1/bridge-tokens/pins': 'the account owner\'s managed-pins switch, in their own signed-in session (Settings in the portal or the app); a CLI bearer (ADMIN_TOKEN) has nobody behind it and is refused by design',
52
+ 'PUT /api/v1/bridge-tokens/account-pairing': 'the account owner\'s account-pairing switch, in their own signed-in session (Settings in the portal or the app); a CLI bearer (ADMIN_TOKEN) has nobody behind it and is refused by design',
53
+ 'POST /api/v1/me/push-devices': 'a phone enrols its own APNs/FCM token, which only the device holds',
54
+ 'DELETE /api/v1/me': 'a person deleting their OWN account, in their own signed-in session with CSRF and their typed address (the portal\'s Account screen, or the app); a CLI bearer (ADMIN_TOKEN) has nobody behind it and is refused by design — an operator erases someone with `users erase`',
55
+ };
56
+
57
+ /**
58
+ * @typedef {'string' | 'integer' | 'number' | 'boolean' | 'json'} FieldType
59
+ * @typedef {{
60
+ * op: string,
61
+ * words: string[],
62
+ * args?: string[],
63
+ * query?: string[],
64
+ * body?: Record<string, FieldType>,
65
+ * summary: string,
66
+ * bespoke?: false,
67
+ * }} GenericRow
68
+ * @typedef {{ op: string, usage: string, summary: string, example: string[], bespoke: true, words?: string[], query?: string[], booleanQuery?: string[] }} BespokeRow
69
+ *
70
+ * A bespoke row DECLARES the query parameters its command sends (`query`,
71
+ * and `booleanQuery` for a bare flag sent as `=1`), and the test holds that
72
+ * list equal to the contract and drives the command with every one set — the
73
+ * same guarantee a generic row gets, without making the command generic.
74
+ *
75
+ * A bespoke row names `words` only where it shares a prefix with a generic
76
+ * row (`connections login` beside `connections`), so the matcher can tell
77
+ * that the longer, hand-written command is the one meant.
78
+ */
79
+
80
+ /** @type {Array<GenericRow | BespokeRow>} */
81
+ export const OPERATIONS = [
82
+ // ── registrations ────────────────────────────────────────────────────────
83
+ {
84
+ op: 'POST /api/v1/registrations',
85
+ bespoke: true,
86
+ usage: 'register --slug <slug> (--npm | --github | --pypi | --mcpb) <spec> [flags] [--data <json>] (no flags on a terminal: the wizard)',
87
+ summary: 'register an MCP',
88
+ example: ['register', '--slug', 'x', '--npm', 'thing-mcp'],
89
+ },
90
+ { op: 'GET /api/v1/registrations', words: ['list'], query: ['account', ...LIST], summary: 'list registrations' },
91
+ { op: 'GET /api/v1/registrations/{id}', words: ['get'], args: ['id'], summary: 'one registration' },
92
+ {
93
+ op: 'PUT /api/v1/registrations/{id}',
94
+ bespoke: true,
95
+ usage:
96
+ 'set <id> [--data-dir|--no-data-dir] [--state-store volume|snapshot] [--follow|--no-follow] [--bridge-approval account|prompt] [--dep …|--no-deps] ' +
97
+ '[--deny destructive|non-read-only|--allow-all] [--ask <tool,…>|--no-ask] [--tools <tool,…>|--all-tools] [--aggregate inline|search|exclude] [--data <json>]',
98
+ summary: 'edit a registration in place (partial patch)',
99
+ example: ['set', 'x', '--follow'],
100
+ },
101
+ { op: 'DELETE /api/v1/registrations/{id}', words: ['rm'], args: ['id'], summary: 'delete a registration' },
102
+ { op: 'GET /api/v1/registrations/{id}/status', words: ['status'], args: ['id'], summary: 'registration plus live instance status' },
103
+ {
104
+ op: 'GET /api/v1/registrations/{id}/logs',
105
+ bespoke: true,
106
+ usage: 'logs <id> [--lines N]',
107
+ query: ['lines'],
108
+ summary: 'tail the child\'s stderr (a bare array of lines)',
109
+ example: ['logs', 'reg_1', '--lines', '5'],
110
+ },
111
+ { op: 'POST /api/v1/registrations/{id}/prewarm', words: ['prewarm'], args: ['id'], summary: 'install and start the instance' },
112
+ { op: 'POST /api/v1/registrations/{id}/restart', words: ['restart'], args: ['id'], summary: 'stop, then re-ensure the instance' },
113
+ {
114
+ op: 'POST /api/v1/registrations/{id}/build',
115
+ bespoke: true,
116
+ usage: 'build <id> [--force] [--wait]',
117
+ booleanQuery: ['force', 'wait'],
118
+ summary: 'ask the builder for a prebuilt install tree',
119
+ example: ['build', 'x'],
120
+ },
121
+ { op: 'GET /api/v1/registrations/{id}/tools', words: ['tools'], args: ['id'], query: LIST, summary: 'one registration\'s tool menu' },
122
+ { op: 'GET /api/v1/tools', words: ['tools'], query: ['account', ...LIST], summary: 'every registration\'s tool menu (no id)' },
123
+ {
124
+ op: 'POST /api/v1/registrations/{id}/call',
125
+ words: ['call'],
126
+ args: ['id', 'tool'],
127
+ body: { args: 'json' },
128
+ summary: 'invoke one tool (--args \'{"k":"v"}\')',
129
+ },
130
+ { op: 'GET /api/v1/registrations/{id}/env', words: ['env'], args: ['id'], query: LIST, summary: 'the env a registration hands its child (names and references)' },
131
+ { op: 'GET /api/v1/registrations/{id}/auth-fields', words: ['auth-fields'], args: ['id'], summary: 'propose a login form from the pinned source\'s metadata' },
132
+ // A READ: records what the package asks for and opens or clears the alert,
133
+ // and applies nothing — acting on an ask is still `set` or a PUT
134
+ // (docs/MINT-MANIFEST.md §9).
135
+ { op: 'POST /api/v1/registrations/{id}/manifest-check', words: ['manifest-check'], args: ['id'], summary: 'read this registration\'s mint.yaml now' },
136
+ { op: 'POST /api/v1/registrations/{id}/manifest-ack', words: ['manifest-ack'], args: ['id'], summary: 'record that the package\'s asks have been read' },
137
+ // Accepting a `losing state` warning for the files it names — no flag
138
+ // flips and nothing restarts; `--names '[".cache"]'` names what is accepted,
139
+ // and without it the open finding's names are (core `state-loss-ack.ts`).
140
+ {
141
+ op: 'POST /api/v1/registrations/{id}/state-loss-ack',
142
+ words: ['state-loss-ack'],
143
+ args: ['id'],
144
+ body: { names: 'json' },
145
+ summary: 'accept losing the files a losing-state warning names (--names \'[".cache"]\')',
146
+ },
147
+ { op: 'DELETE /api/v1/registrations/{id}/state-loss-ack', words: ['state-loss-unack'], args: ['id'], summary: 'undo a losing-state acknowledgement' },
148
+ { op: 'DELETE /api/v1/registrations/{id}/state', words: ['reset', 'state'], args: ['id'], summary: 'throw away the sealed $HOME snapshot' },
149
+ { op: 'DELETE /api/v1/registrations/{id}/lockfile', words: ['reset', 'lockfile'], args: ['id'], summary: 'retire the canonical dependency tree for this pin' },
150
+ { op: 'DELETE /api/v1/registrations/{id}/bridge-identity', words: ['reset', 'bridge-identity'], args: ['id'], summary: 'throw away the fetchproxy identity (re-pairs the browser)' },
151
+ {
152
+ op: 'GET /api/v1/registrations/{id}/bridge-scope',
153
+ words: ['bridge-scope'],
154
+ args: ['id'],
155
+ query: ['account'],
156
+ summary: 'the proposed and approved browser-bridge scope, and who approved it',
157
+ },
158
+ {
159
+ op: 'POST /api/v1/registrations/{id}/bridge-scope/approve',
160
+ words: ['bridge-scope', 'approve'],
161
+ args: ['id'],
162
+ query: ['account'],
163
+ body: { digest: 'string', basis: 'string' },
164
+ summary: 'approve exactly the proposed scope carrying --digest, over the code and settings carrying --basis (409 if either moved)',
165
+ },
166
+ {
167
+ op: 'DELETE /api/v1/registrations/{id}/bridge-scope',
168
+ words: ['bridge-scope', 'retract'],
169
+ args: ['id'],
170
+ query: ['account'],
171
+ summary: 'retract the approved scope back to a proposal',
172
+ },
173
+ {
174
+ op: 'GET /api/v1/registrations/{id}/profiles',
175
+ words: ['profile', 'ls'],
176
+ args: ['id'],
177
+ query: ['account'],
178
+ summary: 'a registration\'s profiles (labeled credential sets): label and secret references',
179
+ },
180
+ // Bespoke for `--secret-env VAR=SECRET`: an overlay is references only, so
181
+ // the common case is "this variable reads that secret", which a nested JSON
182
+ // flag made a shell-quoting exercise. `--env <json>` stays for scripts.
183
+ {
184
+ op: 'POST /api/v1/registrations/{id}/profiles',
185
+ bespoke: true,
186
+ words: ['profile', 'add'],
187
+ usage: 'profile add <id> --label <label> (--secret-env VAR=SECRET … | --env <json>) [--account slug] [--data <json>]',
188
+ query: ['account'],
189
+ summary: 'add a profile: a label and the account secrets its variables read (references only)',
190
+ example: ['profile', 'add', 'reg_1', '--label', 'work', '--secret-env', 'API_TOKEN=WORK_TOKEN'],
191
+ },
192
+ {
193
+ op: 'PUT /api/v1/registrations/{id}/profiles/{profileId}',
194
+ bespoke: true,
195
+ words: ['profile', 'set'],
196
+ usage: 'profile set <id> <profileId> [--label <label>] [--secret-env VAR=SECRET … | --env <json>] [--account slug] [--data <json>]',
197
+ query: ['account'],
198
+ summary: 'rename a profile (--label) or replace its whole overlay (--secret-env / --env)',
199
+ example: ['profile', 'set', 'reg_1', 'rpf_1', '--label', 'office'],
200
+ },
201
+ {
202
+ op: 'DELETE /api/v1/registrations/{id}/profiles/{profileId}',
203
+ words: ['profile', 'rm'],
204
+ args: ['id', 'profileId'],
205
+ query: ['account'],
206
+ summary: 'delete a profile',
207
+ },
208
+ {
209
+ op: 'POST /api/v1/registrations/update-all',
210
+ words: ['update-all'],
211
+ body: { mode: 'string', limit: 'integer', cursor: 'integer', cursorKey: 'string' },
212
+ summary: 're-resolve every enabled registration (--mode follow|force)',
213
+ },
214
+ {
215
+ op: 'POST /api/v1/registrations/preview',
216
+ bespoke: true,
217
+ usage: 'preview (--npm | --github | --pypi | --mcpb) <spec> [--dep …]',
218
+ summary: 'resolve a source to exact pins, write nothing',
219
+ example: ['preview', '--npm', 'thing-mcp'],
220
+ },
221
+ {
222
+ op: 'PUT /api/v1/bundles',
223
+ bespoke: true,
224
+ usage: 'bundles upload <path.mcpb>',
225
+ summary: 'upload an MCPB bundle; prints its sha256',
226
+ example: ['bundles', 'upload', '{mcpb}'],
227
+ },
228
+ { op: 'GET /api/v1/bundles/{sha256}', words: ['bundles', 'get'], args: ['sha256'], summary: 'an uploaded bundle\'s summary' },
229
+ { op: 'GET /api/v1/catalog', words: ['catalog'], query: LIST, summary: 'search the discovery catalogue (--q <text>)' },
230
+
231
+ // ── the account's view ───────────────────────────────────────────────────
232
+ { op: 'GET /api/v1/session', words: ['whoami'], summary: 'who the caller is' },
233
+ { op: 'GET /api/v1/overview', words: ['overview'], query: ['account'], summary: 'health, counts and 24h request stats' },
234
+ { op: 'GET /api/v1/instances', words: ['instances'], query: ['account'], summary: 'every running child in scope' },
235
+ {
236
+ op: 'GET /api/v1/usage',
237
+ words: ['usage'],
238
+ query: ['since', 'until', 'registration', 'bucket', 'account', 'principal', 'days', ...prefixed('reg'), ...prefixed('prin')],
239
+ summary: 'metered usage',
240
+ },
241
+ { op: 'GET /api/v1/billing', words: ['billing'], query: ['period', 'account', 'invoiceCents', ...LIST], summary: 'the month\'s bill and its apportionment' },
242
+ { op: 'GET /api/v1/activity', words: ['activity'], query: ['limit', 'account', ...LIST], summary: 'the audit trail' },
243
+ { op: 'GET /api/v1/alerts', words: ['alerts'], query: ['status', 'kind', 'account', ...LIST], summary: 'open and resolved alerts' },
244
+ // Tool calls a policy said to ask about (spec 2026-10-19 D3). An operator
245
+ // may answer one from outside the account; the owner usually answers from
246
+ // the push on their phone.
247
+ // Bespoke for the summary: stdout stays the JSON, and a line per row on
248
+ // stderr says which MCP, which tool, with what arguments, for how long.
249
+ {
250
+ op: 'GET /api/v1/approvals',
251
+ bespoke: true,
252
+ usage: 'approvals [ls] [--status pending|approved|refused|consumed|expired|all] [--account slug] [--sort v] [--order v] [--q v] [--page v] [--per-page v]',
253
+ query: ['status', 'account', ...LIST],
254
+ summary: 'tool calls waiting for approval: MCP, tool, argument preview, time left',
255
+ example: ['approvals', 'ls'],
256
+ },
257
+ // Bespoke so that it SHOWS the call before letting it through (fleet-audit
258
+ // #1454): an id pasted from a notification was approved without this
259
+ // terminal ever naming the MCP, the tool or the arguments.
260
+ {
261
+ op: 'POST /api/v1/approvals/{id}/approve',
262
+ bespoke: true,
263
+ words: ['approvals', 'approve'],
264
+ usage: 'approvals approve <id> [--account slug] [--yes] (shows the call first; asks, unless --yes)',
265
+ query: ['account'],
266
+ summary: 'let one waiting tool call through, once, after showing which MCP, tool and arguments',
267
+ example: ['approvals', 'approve', 'apr_1', '--yes'],
268
+ },
269
+ { op: 'POST /api/v1/approvals/{id}/refuse', words: ['approvals', 'refuse'], args: ['id'], query: ['account'], summary: 'refuse one waiting tool call' },
270
+
271
+ // ── connections (principals) ─────────────────────────────────────────────
272
+ { op: 'GET /api/v1/principals', words: ['connections'], query: ['registration', 'account', ...LIST], summary: 'every authorization, with the profile (account) each one uses' },
273
+ { op: 'DELETE /api/v1/principals/{id}', words: ['connections', 'revoke'], args: ['id'], summary: 'revoke one connection' },
274
+ { op: 'PUT /api/v1/principals/{id}/label', words: ['connections', 'rename'], args: ['id', 'label'], body: {}, summary: 'name one connection (an empty label clears it)' },
275
+ { op: 'PUT /api/v1/principals/{id}/default', words: ['connections', 'default'], args: ['id'], summary: 'act through this connection of yours on its registration (else the newest)' },
276
+ { op: 'DELETE /api/v1/principals/{id}/default', words: ['connections', 'undefault'], args: ['id'], summary: 'stop preferring this connection: the newest live one is used again' },
277
+ { op: 'GET /api/v1/principals/{id}/secrets', words: ['connections', 'secrets'], args: ['id'], summary: 'one connection\'s credential names' },
278
+ {
279
+ op: 'PUT /api/v1/principals/{id}/secrets/{name}',
280
+ bespoke: true,
281
+ words: ['connections', 'secret', 'set'],
282
+ usage: 'connections secret set <principal id> <NAME> [--value v | --from-env [VAR]]',
283
+ summary: 'repair one connection\'s credential',
284
+ example: ['connections', 'secret', 'set', 'p1', 'NAME', '--value', 'v'],
285
+ },
286
+ { op: 'DELETE /api/v1/principals/{id}/secrets/{name}', words: ['connections', 'secret', 'rm'], args: ['id', 'name'], summary: 'remove one connection\'s credential' },
287
+ {
288
+ op: 'POST /api/v1/principals/{id}/login',
289
+ bespoke: true,
290
+ words: ['connections', 'login'],
291
+ usage: 'connections login <principal id> (on a terminal, answers each step\'s prompts)',
292
+ summary: 'run the connection\'s declared sign-in again',
293
+ example: ['connections', 'login', 'p1'],
294
+ },
295
+ {
296
+ op: 'POST /api/v1/principals/{id}/login/step',
297
+ words: ['connections', 'login-step'],
298
+ args: ['id'],
299
+ body: { login: 'string', step: 'integer', answers: 'json' },
300
+ summary: 'answer one sign-in step (the scripted form of `connections login`)',
301
+ },
302
+
303
+ // ── you ──────────────────────────────────────────────────────────────────
304
+ { op: 'GET /api/v1/me', words: ['me'], summary: 'your own preferences (whether alerts about your MCPs are pushed to you)' },
305
+ // Bespoke, not a boolean flag: a bare boolean can only send `true`, and the
306
+ // switch is mostly used to turn alerts OFF — `off` is what anyone types,
307
+ // which a JSON-typed flag refused as "takes JSON".
308
+ {
309
+ op: 'PUT /api/v1/me',
310
+ bespoke: true,
311
+ words: ['me', 'set'],
312
+ usage: 'me set --push-tenant-alerts on|off [--data <json>]',
313
+ summary: 'set your own preferences (whether alerts about your MCPs are pushed to your devices)',
314
+ example: ['me', 'set', '--push-tenant-alerts', 'off'],
315
+ },
316
+ { op: 'GET /api/v1/me/identities', words: ['me', 'identities'], summary: 'your linked sign-in methods' },
317
+ { op: 'DELETE /api/v1/me/identities/{provider}/{subject}', words: ['me', 'identities', 'rm'], args: ['provider', 'subject'], summary: 'unlink a sign-in method' },
318
+ { op: 'GET /api/v1/me/push-devices', words: ['me', 'devices'], summary: 'devices enrolled for alerts' },
319
+ { op: 'DELETE /api/v1/me/push-devices/{id}', words: ['me', 'devices', 'rm'], args: ['id'], summary: 'stop alerting a device' },
320
+ { op: 'PATCH /api/v1/me/account', words: ['me', 'rename'], args: ['slug'], body: {}, summary: 'rename your account slug' },
321
+
322
+ // ── users, accounts, invites ─────────────────────────────────────────────
323
+ { op: 'GET /api/v1/users', words: ['users'], query: ['account', ...LIST], summary: 'every user' },
324
+ { op: 'PUT /api/v1/users/{idOrEmail}/role', words: ['users', 'role'], args: ['idOrEmail', 'role'], body: {}, summary: 'promote or demote (user|super)' },
325
+ { op: 'DELETE /api/v1/users/{idOrEmail}', words: ['users', 'erase'], args: ['idOrEmail'], summary: 'erase a person' },
326
+ { op: 'GET /api/v1/users/{idOrEmail}/entitlements', words: ['users', 'entitlements'], args: ['idOrEmail'], summary: 'a person\'s granted entitlements' },
327
+ { op: 'PUT /api/v1/users/{idOrEmail}/entitlements/{name}', words: ['users', 'grant'], args: ['idOrEmail', 'name'], summary: 'grant an entitlement (super-admin only)' },
328
+ { op: 'DELETE /api/v1/users/{idOrEmail}/entitlements/{name}', words: ['users', 'revoke'], args: ['idOrEmail', 'name'], summary: 'revoke an entitlement (super-admin only)' },
329
+ { op: 'GET /api/v1/accounts/{idOrSlug}/limits', words: ['limits'], args: ['idOrSlug'], summary: 'an account\'s quotas' },
330
+ {
331
+ op: 'PUT /api/v1/accounts/{idOrSlug}/limits',
332
+ words: ['limits', 'set'],
333
+ args: ['idOrSlug'],
334
+ body: { maxRegistrations: 'integer', maxCallsPerMinute: 'integer', maxBundleBytes: 'integer', minSecurityScore: 'number' },
335
+ // No "reset to default": the fields are optional, not nullable, and the
336
+ // write COALESCEs, so an omitted field keeps its current value.
337
+ summary: 'raise or lower an account\'s quotas (an omitted field keeps its value)',
338
+ },
339
+ { op: 'GET /api/v1/invites', words: ['invites'], query: ['account', ...LIST], summary: 'outstanding invites' },
340
+ {
341
+ op: 'POST /api/v1/invites',
342
+ words: ['invites', 'new'],
343
+ body: { email: 'string', account: 'string', note: 'string', expiresAt: 'string' },
344
+ summary: 'mint an invite',
345
+ },
346
+ { op: 'DELETE /api/v1/invites/{id}', words: ['invites', 'revoke'], args: ['id'], summary: 'revoke an unredeemed invite' },
347
+
348
+ // ── secrets ──────────────────────────────────────────────────────────────
349
+ {
350
+ op: 'PUT /api/v1/secrets/{name}',
351
+ bespoke: true,
352
+ usage: 'secret set <NAME> [--value v | --from-env [VAR]] [--legacy] [--account slug] (sealed before it leaves this process)',
353
+ query: ['account'],
354
+ summary: 'set or rotate a secret',
355
+ example: ['secret', 'set', 'NAME', '--value', 'v', '--legacy'],
356
+ },
357
+ {
358
+ op: 'GET /api/v1/secrets',
359
+ bespoke: true,
360
+ usage: 'secret ls [--account slug] [--sort v] [--order v] [--q v] [--page v] [--per-page v] | secret fingerprint <NAME> [--value v | --from-env [VAR]] [--account slug]',
361
+ query: ['account', ...LIST],
362
+ summary: 'list secrets, or compare a value against a stored fingerprint locally',
363
+ example: ['secret', 'ls'],
364
+ },
365
+ {
366
+ op: 'DELETE /api/v1/secrets/{name}',
367
+ bespoke: true,
368
+ usage: 'secret rm <NAME> [--force] [--account slug] (--force deletes one a registration or profile still uses)',
369
+ query: ['account'],
370
+ booleanQuery: ['force'],
371
+ summary: 'delete a secret (refused while in use unless --force)',
372
+ example: ['secret', 'rm', 'NAME'],
373
+ },
374
+ { op: 'GET /api/v1/secrets/public-key', words: ['secret', 'public-key'], query: ['account'], summary: 'the account seal key' },
375
+ { op: 'POST /api/v1/secrets/reseal', words: ['secret', 'reseal'], body: { limit: 'integer', cursor: 'string' }, summary: 'seal every legacy row (one-time migration)' },
376
+ { op: 'POST /api/v1/secrets/rotate', words: ['secret', 'rotate'], body: { limit: 'integer', cursor: 'string' }, summary: 're-wrap sealed rows after a seal-root rotation' },
377
+
378
+ // ── browser bridge, marketplace ──────────────────────────────────────────
379
+ {
380
+ op: 'GET /api/v1/bridge-tokens',
381
+ bespoke: true,
382
+ usage: 'bridge [ls] [--account slug] [--sort v] [--order v] [--q v] [--page v] [--per-page v]',
383
+ query: ['account', ...LIST],
384
+ // connectedNow/connectedSince (D18, spec §4.13) are answered to the
385
+ // account OWNER's own session only; this CLI authenticates with
386
+ // ADMIN_TOKEN, so its listing never carries them (the portal and the app
387
+ // show them). Printed as-is, the column appears whenever the API sends it.
388
+ summary: 'list bridge credentials (browsers connect from the extension; connected-now is the owner\'s session only)',
389
+ example: ['bridge', 'ls', '--account', 'acme'],
390
+ },
391
+ {
392
+ op: 'GET /api/v1/bridge-tokens/pins',
393
+ words: ['bridge', 'pins'],
394
+ query: ['account'],
395
+ summary: 'show whether the account is on managed extension pins, and why',
396
+ },
397
+ {
398
+ op: 'GET /api/v1/bridge-tokens/account-pairing',
399
+ words: ['bridge', 'account-pairing'],
400
+ query: ['account'],
401
+ summary: 'show whether account pairing is on for the account, and why',
402
+ },
403
+ {
404
+ op: 'POST /api/v1/bridge-tokens/unpair-account',
405
+ words: ['bridge', 'unpair-account'],
406
+ query: ['account'],
407
+ summary: 'unpair the account: every browser sees a new account key (owner only)',
408
+ },
409
+ {
410
+ op: 'POST /api/v1/bridge-tokens/mac-pass',
411
+ words: ['bridge', 'mac-pass'],
412
+ body: { dryRun: 'boolean', rotate: 'boolean', limit: 'integer', cursor: 'string' },
413
+ summary: 'MAC bridge credentials minted before BRIDGE_ATTEST_ROOT (a dry run unless --data {"dryRun":false}; scripts/bridge-mac-pass.mjs --apply)',
414
+ },
415
+ {
416
+ op: 'DELETE /api/v1/bridge-tokens/{id}',
417
+ bespoke: true,
418
+ usage: 'bridge revoke <id> [--account slug]',
419
+ query: ['account'],
420
+ summary: 'revoke a bridge credential',
421
+ example: ['bridge', 'revoke', 'b1', '--account', 'acme'],
422
+ },
423
+ {
424
+ op: 'GET /api/v1/connector',
425
+ bespoke: true,
426
+ usage: 'connector [--account slug] [--json]',
427
+ query: ['account'],
428
+ summary: 'print the account\'s one connect URL for every MCP (the aggregate connector) and what it reaches',
429
+ example: ['connector', '--account', 'acme'],
430
+ },
431
+ { op: 'GET /api/v1/connector/connections', words: ['connector', 'connections'], query: ['account'], summary: 'your connections through the aggregate connector (one per approval), each revocable' },
432
+ { op: 'DELETE /api/v1/connector/connections/{id}', words: ['connector', 'revoke'], args: ['id'], query: ['account'], summary: 'revoke ONE aggregate connection: its tokens stop working at once' },
433
+ { op: 'DELETE /api/v1/connector/connections', words: ['connector', 'revoke-all'], query: ['account'], summary: 'revoke every one of your aggregate connections in this account' },
434
+ {
435
+ op: 'GET /api/v1/marketplace',
436
+ bespoke: true,
437
+ usage: 'marketplace [--account slug]',
438
+ query: ['account'],
439
+ summary: 'whether a marketplace feed exists',
440
+ example: ['marketplace', '--account', 'acme'],
441
+ },
442
+ {
443
+ op: 'POST /api/v1/marketplace/token',
444
+ bespoke: true,
445
+ usage: 'marketplace rotate [--account slug]',
446
+ query: ['account'],
447
+ summary: 'mint (or rotate) the marketplace URL',
448
+ example: ['marketplace', 'rotate', '--account', 'acme'],
449
+ },
450
+ {
451
+ op: 'DELETE /api/v1/marketplace/token',
452
+ bespoke: true,
453
+ usage: 'marketplace revoke [--account slug]',
454
+ query: ['account'],
455
+ summary: 'revoke the marketplace URL',
456
+ example: ['marketplace', 'revoke', '--account', 'acme'],
457
+ },
458
+
459
+ // ── infrastructure (super-admin) ─────────────────────────────────────────
460
+ {
461
+ op: 'GET /api/v1/infra',
462
+ bespoke: true,
463
+ usage: 'infra [--days N] [--account slug] [--res-sort v] [--res-order v] [--res-q v] [--res-page v] [--res-per-page v]',
464
+ query: ['account', 'days', ...prefixed('res')],
465
+ summary: 'machines, the shared pool, resource totals',
466
+ example: ['infra', '--days', '7'],
467
+ },
468
+ {
469
+ op: 'POST /api/v1/infra/shared-machines/sync',
470
+ bespoke: true,
471
+ usage: 'infra sync',
472
+ summary: 'bring the pool\'s machine set in step with Fly',
473
+ example: ['infra', 'sync'],
474
+ },
475
+ {
476
+ op: 'POST /api/v1/infra/shared-machines/{id}/drain',
477
+ bespoke: true,
478
+ usage: 'infra drain <machine id>',
479
+ summary: 'take a pool machine out of placement',
480
+ example: ['infra', 'drain', 'm1'],
481
+ },
482
+ {
483
+ op: 'POST /api/v1/infra/shared-machines/{id}/undrain',
484
+ bespoke: true,
485
+ usage: 'infra undrain <machine id>',
486
+ summary: 'return a drained machine to placement',
487
+ example: ['infra', 'undrain', 'm1'],
488
+ },
489
+ ];
490
+
491
+ const kebabList = (prefix = '') => LIST.map((n) => flagName(prefix ? `${prefix}${n[0].toUpperCase()}${n.slice(1)}` : n));
492
+
493
+ /**
494
+ * The flags each HAND-WRITTEN command accepts (fleet-audit#1407), keyed by the
495
+ * words that name it. A generic row's flags are derived from the row; a
496
+ * bespoke command's were whatever its code happened to read, so a typo beside
497
+ * a valid flag was dropped and the request still sent. index.mjs parses each
498
+ * of these commands against its entry and REFUSES anything outside it, with
499
+ * the usage line of the row named by `op`.
500
+ *
501
+ * `values` take a value, `booleans` take none, `optional` may take one
502
+ * (`--from-env [VAR]`). Held to an enumeration in tests/bespoke-flags.test.ts.
503
+ *
504
+ * @type {Record<string, { op: string, values?: string[], booleans?: string[], optional?: string[] }>}
505
+ */
506
+ export const COMMAND_FLAGS = {
507
+ register: {
508
+ op: 'POST /api/v1/registrations',
509
+ values: ['slug', 'npm', 'github', 'pypi', 'mcpb', 'subdir', 'build', 'bin', 'dep', 'dep-sha256', 'account', 'name', 'env', 'secret-env', 'tools', 'auth-flow', 'data'],
510
+ booleans: ['follow', 'data-dir', 'no-data-dir', 'per-user-child', 'bridge'],
511
+ },
512
+ set: {
513
+ op: 'PUT /api/v1/registrations/{id}',
514
+ values: ['data', 'state-store', 'dep', 'dep-sha256', 'bridge-approval', 'deny', 'ask', 'tools', 'aggregate'],
515
+ booleans: ['data-dir', 'no-data-dir', 'no-deps', 'follow', 'no-follow', 'allow-all', 'no-ask', 'all-tools'],
516
+ },
517
+ logs: { op: 'GET /api/v1/registrations/{id}/logs', values: ['lines'] },
518
+ build: { op: 'POST /api/v1/registrations/{id}/build', booleans: ['force', 'wait'] },
519
+ preview: { op: 'POST /api/v1/registrations/preview', values: ['npm', 'github', 'pypi', 'mcpb', 'subdir', 'build', 'dep', 'dep-sha256'] },
520
+ 'bundles upload': { op: 'PUT /api/v1/bundles' },
521
+ approvals: { op: 'GET /api/v1/approvals', values: ['status', 'account', ...kebabList()] },
522
+ 'approvals approve': { op: 'POST /api/v1/approvals/{id}/approve', values: ['account'], booleans: ['yes'] },
523
+ 'me set': { op: 'PUT /api/v1/me', values: ['push-tenant-alerts', 'data'] },
524
+ 'profile add': { op: 'POST /api/v1/registrations/{id}/profiles', values: ['label', 'secret-env', 'env', 'account', 'data'] },
525
+ 'profile set': { op: 'PUT /api/v1/registrations/{id}/profiles/{profileId}', values: ['label', 'secret-env', 'env', 'account', 'data'] },
526
+ 'connections login': { op: 'POST /api/v1/principals/{id}/login' },
527
+ 'connections secret set': { op: 'PUT /api/v1/principals/{id}/secrets/{name}', values: ['value'], optional: ['from-env'] },
528
+ 'secret set': { op: 'PUT /api/v1/secrets/{name}', values: ['value', 'account'], booleans: ['legacy'], optional: ['from-env'] },
529
+ 'secret fingerprint': { op: 'GET /api/v1/secrets', values: ['value', 'account'], optional: ['from-env'] },
530
+ 'secret ls': { op: 'GET /api/v1/secrets', values: ['account', ...kebabList()] },
531
+ 'secret rm': { op: 'DELETE /api/v1/secrets/{name}', values: ['account'], booleans: ['force'] },
532
+ bridge: { op: 'GET /api/v1/bridge-tokens', values: ['account', ...kebabList()] },
533
+ 'bridge revoke': { op: 'DELETE /api/v1/bridge-tokens/{id}', values: ['account'] },
534
+ connector: { op: 'GET /api/v1/connector', values: ['account'], booleans: ['json'] },
535
+ marketplace: { op: 'GET /api/v1/marketplace', values: ['account'] },
536
+ 'marketplace rotate': { op: 'POST /api/v1/marketplace/token', values: ['account'] },
537
+ 'marketplace revoke': { op: 'DELETE /api/v1/marketplace/token', values: ['account'] },
538
+ infra: { op: 'GET /api/v1/infra', values: ['account', 'days', ...kebabList('res')] },
539
+ 'infra sync': { op: 'POST /api/v1/infra/shared-machines/sync' },
540
+ 'infra drain': { op: 'POST /api/v1/infra/shared-machines/{id}/drain' },
541
+ 'infra undrain': { op: 'POST /api/v1/infra/shared-machines/{id}/undrain' },
542
+ };
543
+
544
+ /** `usage: mcp-host …` for a COMMAND_FLAGS key: its row's usage line, so the refusal and `--help` say one thing. */
545
+ export function commandUsage(key) {
546
+ const row = OPERATIONS.find((r) => r.op === COMMAND_FLAGS[key].op);
547
+ return `usage: mcp-host ${usageFor(row)}`;
548
+ }
549
+
550
+ /** `perPage` → `per-page`: the flag spelling of a query or body field name. */
551
+ export function flagName(field) {
552
+ return field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
553
+ }
554
+
555
+ /** The path parameters of an operation, in the order they appear. */
556
+ export function pathParams(op) {
557
+ return [...op.split(' ')[1].matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
558
+ }
559
+
560
+ /** The usage line for a row — written out for a bespoke row, derived for a generic one. */
561
+ export function usageFor(row) {
562
+ if (row.bespoke) return row.usage;
563
+ const parts = [...row.words, ...(row.args ?? []).map((a) => `<${a}>`)];
564
+ for (const q of row.query ?? []) parts.push(`[--${flagName(q)} v]`);
565
+ for (const [field, type] of Object.entries(row.body ?? {})) {
566
+ parts.push(type === 'boolean' ? `[--${flagName(field)}]` : `[--${flagName(field)} ${type === 'json' ? 'json' : 'v'}]`);
567
+ }
568
+ if (row.body) parts.push('[--data <json|@file|->]');
569
+ return parts.join(' ');
570
+ }
571
+
572
+ /** The argv a generic row is exercised with: its words and a placeholder per positional. */
573
+ export function exampleFor(row) {
574
+ if (row.bespoke) return row.example;
575
+ return [...row.words, ...(row.args ?? []).map((a, i) => `${a}${i}`)];
576
+ }
577
+
578
+ /**
579
+ * The generic row an argv names, or undefined. The LONGEST word prefix wins
580
+ * (`connections secret rm` over `connections`), and among rows sharing words
581
+ * the one whose positional count matches (`tools` vs `tools <id>`). Called on
582
+ * the WORDS only — flags are parsed by the row once it is chosen, since
583
+ * which names are flags depends on the row.
584
+ *
585
+ * @param {string[]} argv
586
+ */
587
+ export function matchGeneric(argv) {
588
+ const leading = [];
589
+ for (const arg of argv) {
590
+ if (arg.startsWith('--')) break;
591
+ leading.push(arg);
592
+ }
593
+ const candidates = OPERATIONS.filter(
594
+ (row) => row.words !== undefined && row.words.length <= leading.length && row.words.every((w, i) => leading[i] === w),
595
+ );
596
+ if (candidates.length === 0) return undefined;
597
+ const longest = Math.max(...candidates.map((row) => row.words.length));
598
+ const tied = candidates.filter((row) => row.words.length === longest);
599
+ // A longer bespoke command wins over a shorter generic one, and is main()'s.
600
+ if (tied.some((row) => row.bespoke)) return undefined;
601
+ if (tied.length === 1) return tied[0];
602
+ // A shared prefix: decide by how many positionals follow the words. Counted
603
+ // over the whole argv's positionals would need the row's flag set to skip
604
+ // flag values, so count only the run before the first flag — which is where
605
+ // every usage line puts them.
606
+ const given = leading.length - longest;
607
+ return tied.find((row) => (row.args ?? []).length === given) ?? tied[0];
608
+ }
609
+
610
+ /**
611
+ * Help for what was typed: the one row a command names (`list --help`), else
612
+ * every row under the words given (`secret --help`), else everything. Matched
613
+ * on the leading WORDS of each row's usage line, so bespoke rows — which have
614
+ * no `words` — are found by what they print.
615
+ *
616
+ * @param {string[]} argv the command line with `--help` removed
617
+ */
618
+ export function helpFor(argv) {
619
+ const leading = [];
620
+ for (const arg of argv) {
621
+ if (arg.startsWith('-')) break;
622
+ leading.push(arg);
623
+ }
624
+ if (leading.length === 0) return helpText();
625
+ const row = matchGeneric(leading);
626
+ if (row && row.words.length === leading.length) return `usage: mcp-host ${usageFor(row)}`;
627
+ for (let k = leading.length; k > 0; k--) {
628
+ const prefix = leading.slice(0, k);
629
+ const lines = OPERATIONS.map(usageFor).filter((usage) => {
630
+ const words = usage.split(' ');
631
+ return prefix.every((w, i) => words[i] === w || (i === 1 && words[i] === `[${w}]`));
632
+ });
633
+ if (lines.length === 1) return `usage: mcp-host ${lines[0]}`;
634
+ if (lines.length > 1) return lines.map((u) => ` mcp-host ${u}`).join('\n');
635
+ }
636
+ return helpText();
637
+ }
638
+
639
+ /** The help text: every reachable operation, one line each. */
640
+ export function helpText() {
641
+ const lines = ['usage: mcp-host <command> [--insecure]', ''];
642
+ const rows = OPERATIONS.map((row) => [usageFor(row), row.summary]);
643
+ const width = Math.min(72, Math.max(...rows.map(([u]) => u.length)));
644
+ for (const [usage, summary] of rows) {
645
+ lines.push(usage.length > width ? ` ${usage}\n ${' '.repeat(width)} ${summary}` : ` ${usage.padEnd(width)} ${summary}`);
646
+ }
647
+ lines.push('', ' wizard register interactively', ' builder-key LEGACY: print HMAC(RUNNER_KEY, \'builder\')');
648
+ lines.push('', 'MCP_HOST_URL and MCP_HOST_ADMIN_TOKEN must be set. Output is JSON on stdout.');
649
+ return lines.join('\n');
650
+ }