@chrischall/mcp-host-cli 0.0.0-stage → 2.33.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/src/login.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // `connections login <principal id>`: run a connection's declared sign-in
2
+ // again (`authFlow`, core/src/auth-flow.ts) — the one repair for a credential
3
+ // that expired upstream short of revoking the connection, which loses the
4
+ // grant and its usage history.
5
+ //
6
+ // On a terminal it is the conversation `/authorize` would have had: each
7
+ // step's prompts asked here (a password without echo, the schema's default
8
+ // type), answered through `/login/step` until the flow ends. Off a terminal it
9
+ // prints nothing but the first answer and says how to continue with
10
+ // `connections login-step`, because a script cannot be prompted and should
11
+ // state its answers itself. Pulled out of index.mjs so the loop can be driven
12
+ // by a scripted prompter: the CLI's own tests run over a pipe and so only ever
13
+ // reach the non-interactive branch.
14
+
15
+ /** Every state a login can end in that is not success (core `loginStateSchema`). */
16
+ const FAILED = new Set(['rejected', 'unavailable', 'incoherent']);
17
+
18
+ /**
19
+ * @param {(method: string, path: string, body?: unknown) => Promise<any>} api
20
+ * @param {string} id
21
+ * @param {{ interactive: boolean, prompter?: import('./prompt.mjs').Prompter, stderr?: { write(s: string): unknown } }} opts
22
+ * @returns {Promise<{ result: any, exitCode: number }>}
23
+ */
24
+ export async function runLogin(api, id, { interactive, prompter, stderr = process.stderr }) {
25
+ const base = `/api/v1/principals/${encodeURIComponent(id)}/login`;
26
+ let result = await api('POST', base);
27
+ if (interactive && prompter) {
28
+ while (result.state === 'prompt') {
29
+ if (result.title) stderr.write(`${result.title}\n`);
30
+ if (result.description) stderr.write(`${result.description}\n`);
31
+ // Resolved by the server against the hosts the flow declared; absent
32
+ // when it was not on one, so there is nothing here to check.
33
+ if (result.link) stderr.write(`${result.link.label}: ${result.link.url}\n`);
34
+ /** @type {Record<string, string>} */
35
+ const answers = {};
36
+ for (const p of result.prompts ?? []) {
37
+ if (p.type === 'choice') answers[p.name] = await prompter.choose(p.label, p.options);
38
+ else if (p.type === 'text') answers[p.name] = await prompter.ask(p.label);
39
+ else answers[p.name] = await prompter.secret(p.label);
40
+ }
41
+ result = await api('POST', `${base}/step`, { login: result.login, step: result.step, answers });
42
+ }
43
+ return { result, exitCode: result.state === 'done' ? 0 : 1 };
44
+ }
45
+ if (result.state === 'prompt') {
46
+ stderr.write(
47
+ `mcp-host: continue with: mcp-host connections login-step ${id} --login ${result.login} --step ${result.step} --answers '{"<name>":"<value>"}'\n`,
48
+ );
49
+ }
50
+ // A prompt is not a failure off a terminal — it is the flow waiting for the
51
+ // scripted answer — but a refusal is, on either path.
52
+ return { result, exitCode: FAILED.has(result.state) ? 1 : 0 };
53
+ }
@@ -0,0 +1,579 @@
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
+ { op: 'POST /api/v1/approvals/{id}/approve', words: ['approvals', 'approve'], args: ['id'], query: ['account'], summary: 'let one waiting tool call through, once' },
258
+ { op: 'POST /api/v1/approvals/{id}/refuse', words: ['approvals', 'refuse'], args: ['id'], query: ['account'], summary: 'refuse one waiting tool call' },
259
+
260
+ // ── connections (principals) ─────────────────────────────────────────────
261
+ { op: 'GET /api/v1/principals', words: ['connections'], query: ['registration', 'account', ...LIST], summary: 'every authorization, with the profile (account) each one uses' },
262
+ { op: 'DELETE /api/v1/principals/{id}', words: ['connections', 'revoke'], args: ['id'], summary: 'revoke one connection' },
263
+ { op: 'PUT /api/v1/principals/{id}/label', words: ['connections', 'rename'], args: ['id', 'label'], body: {}, summary: 'name one connection (an empty label clears it)' },
264
+ { 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)' },
265
+ { op: 'DELETE /api/v1/principals/{id}/default', words: ['connections', 'undefault'], args: ['id'], summary: 'stop preferring this connection: the newest live one is used again' },
266
+ { op: 'GET /api/v1/principals/{id}/secrets', words: ['connections', 'secrets'], args: ['id'], summary: 'one connection\'s credential names' },
267
+ {
268
+ op: 'PUT /api/v1/principals/{id}/secrets/{name}',
269
+ bespoke: true,
270
+ words: ['connections', 'secret', 'set'],
271
+ usage: 'connections secret set <principal id> <NAME> [--value v | --from-env [VAR]]',
272
+ summary: 'repair one connection\'s credential',
273
+ example: ['connections', 'secret', 'set', 'p1', 'NAME', '--value', 'v'],
274
+ },
275
+ { op: 'DELETE /api/v1/principals/{id}/secrets/{name}', words: ['connections', 'secret', 'rm'], args: ['id', 'name'], summary: 'remove one connection\'s credential' },
276
+ {
277
+ op: 'POST /api/v1/principals/{id}/login',
278
+ bespoke: true,
279
+ words: ['connections', 'login'],
280
+ usage: 'connections login <principal id> (on a terminal, answers each step\'s prompts)',
281
+ summary: 'run the connection\'s declared sign-in again',
282
+ example: ['connections', 'login', 'p1'],
283
+ },
284
+ {
285
+ op: 'POST /api/v1/principals/{id}/login/step',
286
+ words: ['connections', 'login-step'],
287
+ args: ['id'],
288
+ body: { login: 'string', step: 'integer', answers: 'json' },
289
+ summary: 'answer one sign-in step (the scripted form of `connections login`)',
290
+ },
291
+
292
+ // ── you ──────────────────────────────────────────────────────────────────
293
+ { op: 'GET /api/v1/me', words: ['me'], summary: 'your own preferences (whether alerts about your MCPs are pushed to you)' },
294
+ // Bespoke, not a boolean flag: a bare boolean can only send `true`, and the
295
+ // switch is mostly used to turn alerts OFF — `off` is what anyone types,
296
+ // which a JSON-typed flag refused as "takes JSON".
297
+ {
298
+ op: 'PUT /api/v1/me',
299
+ bespoke: true,
300
+ words: ['me', 'set'],
301
+ usage: 'me set --push-tenant-alerts on|off',
302
+ summary: 'set your own preferences (whether alerts about your MCPs are pushed to your devices)',
303
+ example: ['me', 'set', '--push-tenant-alerts', 'off'],
304
+ },
305
+ { op: 'GET /api/v1/me/identities', words: ['me', 'identities'], summary: 'your linked sign-in methods' },
306
+ { op: 'DELETE /api/v1/me/identities/{provider}/{subject}', words: ['me', 'identities', 'rm'], args: ['provider', 'subject'], summary: 'unlink a sign-in method' },
307
+ { op: 'GET /api/v1/me/push-devices', words: ['me', 'devices'], summary: 'devices enrolled for alerts' },
308
+ { op: 'DELETE /api/v1/me/push-devices/{id}', words: ['me', 'devices', 'rm'], args: ['id'], summary: 'stop alerting a device' },
309
+ { op: 'PATCH /api/v1/me/account', words: ['me', 'rename'], args: ['slug'], body: {}, summary: 'rename your account slug' },
310
+
311
+ // ── users, accounts, invites ─────────────────────────────────────────────
312
+ { op: 'GET /api/v1/users', words: ['users'], query: ['account', ...LIST], summary: 'every user' },
313
+ { op: 'PUT /api/v1/users/{idOrEmail}/role', words: ['users', 'role'], args: ['idOrEmail', 'role'], body: {}, summary: 'promote or demote (user|super)' },
314
+ { op: 'DELETE /api/v1/users/{idOrEmail}', words: ['users', 'erase'], args: ['idOrEmail'], summary: 'erase a person' },
315
+ { op: 'GET /api/v1/users/{idOrEmail}/entitlements', words: ['users', 'entitlements'], args: ['idOrEmail'], summary: 'a person\'s granted entitlements' },
316
+ { op: 'PUT /api/v1/users/{idOrEmail}/entitlements/{name}', words: ['users', 'grant'], args: ['idOrEmail', 'name'], summary: 'grant an entitlement (super-admin only)' },
317
+ { op: 'DELETE /api/v1/users/{idOrEmail}/entitlements/{name}', words: ['users', 'revoke'], args: ['idOrEmail', 'name'], summary: 'revoke an entitlement (super-admin only)' },
318
+ { op: 'GET /api/v1/accounts/{idOrSlug}/limits', words: ['limits'], args: ['idOrSlug'], summary: 'an account\'s quotas' },
319
+ {
320
+ op: 'PUT /api/v1/accounts/{idOrSlug}/limits',
321
+ words: ['limits', 'set'],
322
+ args: ['idOrSlug'],
323
+ body: { maxRegistrations: 'integer', maxCallsPerMinute: 'integer', maxBundleBytes: 'integer', minSecurityScore: 'number' },
324
+ // No "reset to default": the fields are optional, not nullable, and the
325
+ // write COALESCEs, so an omitted field keeps its current value.
326
+ summary: 'raise or lower an account\'s quotas (an omitted field keeps its value)',
327
+ },
328
+ { op: 'GET /api/v1/invites', words: ['invites'], query: ['account', ...LIST], summary: 'outstanding invites' },
329
+ {
330
+ op: 'POST /api/v1/invites',
331
+ words: ['invites', 'new'],
332
+ body: { email: 'string', account: 'string', note: 'string', expiresAt: 'string' },
333
+ summary: 'mint an invite',
334
+ },
335
+ { op: 'DELETE /api/v1/invites/{id}', words: ['invites', 'revoke'], args: ['id'], summary: 'revoke an unredeemed invite' },
336
+
337
+ // ── secrets ──────────────────────────────────────────────────────────────
338
+ {
339
+ op: 'PUT /api/v1/secrets/{name}',
340
+ bespoke: true,
341
+ usage: 'secret set <NAME> [--value v | --from-env [VAR]] [--legacy] [--account slug] (sealed before it leaves this process)',
342
+ query: ['account'],
343
+ summary: 'set or rotate a secret',
344
+ example: ['secret', 'set', 'NAME', '--value', 'v', '--legacy'],
345
+ },
346
+ {
347
+ op: 'GET /api/v1/secrets',
348
+ bespoke: true,
349
+ 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]',
350
+ query: ['account', ...LIST],
351
+ summary: 'list secrets, or compare a value against a stored fingerprint locally',
352
+ example: ['secret', 'ls'],
353
+ },
354
+ {
355
+ op: 'DELETE /api/v1/secrets/{name}',
356
+ bespoke: true,
357
+ usage: 'secret rm <NAME> [--account slug]',
358
+ query: ['account'],
359
+ summary: 'delete a secret',
360
+ example: ['secret', 'rm', 'NAME'],
361
+ },
362
+ { op: 'GET /api/v1/secrets/public-key', words: ['secret', 'public-key'], query: ['account'], summary: 'the account seal key' },
363
+ { op: 'POST /api/v1/secrets/reseal', words: ['secret', 'reseal'], body: { limit: 'integer', cursor: 'string' }, summary: 'seal every legacy row (one-time migration)' },
364
+ { op: 'POST /api/v1/secrets/rotate', words: ['secret', 'rotate'], body: { limit: 'integer', cursor: 'string' }, summary: 're-wrap sealed rows after a seal-root rotation' },
365
+
366
+ // ── browser bridge, marketplace ──────────────────────────────────────────
367
+ {
368
+ op: 'GET /api/v1/bridge-tokens',
369
+ bespoke: true,
370
+ usage: 'bridge [ls] [--account slug] [--sort v] [--order v] [--q v] [--page v] [--per-page v]',
371
+ query: ['account', ...LIST],
372
+ // connectedNow/connectedSince (D18, spec §4.13) are answered to the
373
+ // account OWNER's own session only; this CLI authenticates with
374
+ // ADMIN_TOKEN, so its listing never carries them (the portal and the app
375
+ // show them). Printed as-is, the column appears whenever the API sends it.
376
+ summary: 'list bridge credentials (browsers connect from the extension; connected-now is the owner\'s session only)',
377
+ example: ['bridge', 'ls', '--account', 'acme'],
378
+ },
379
+ {
380
+ op: 'GET /api/v1/bridge-tokens/pins',
381
+ words: ['bridge', 'pins'],
382
+ query: ['account'],
383
+ summary: 'show whether the account is on managed extension pins, and why',
384
+ },
385
+ {
386
+ op: 'GET /api/v1/bridge-tokens/account-pairing',
387
+ words: ['bridge', 'account-pairing'],
388
+ query: ['account'],
389
+ summary: 'show whether account pairing is on for the account, and why',
390
+ },
391
+ {
392
+ op: 'POST /api/v1/bridge-tokens/unpair-account',
393
+ words: ['bridge', 'unpair-account'],
394
+ query: ['account'],
395
+ summary: 'unpair the account: every browser sees a new account key (owner only)',
396
+ },
397
+ {
398
+ op: 'POST /api/v1/bridge-tokens/mac-pass',
399
+ words: ['bridge', 'mac-pass'],
400
+ body: { dryRun: 'boolean', rotate: 'boolean', limit: 'integer', cursor: 'string' },
401
+ summary: 'MAC bridge credentials minted before BRIDGE_ATTEST_ROOT (a dry run unless --data {"dryRun":false}; scripts/bridge-mac-pass.mjs --apply)',
402
+ },
403
+ {
404
+ op: 'DELETE /api/v1/bridge-tokens/{id}',
405
+ bespoke: true,
406
+ usage: 'bridge revoke <id> [--account slug]',
407
+ query: ['account'],
408
+ summary: 'revoke a bridge credential',
409
+ example: ['bridge', 'revoke', 'b1', '--account', 'acme'],
410
+ },
411
+ {
412
+ op: 'GET /api/v1/connector',
413
+ bespoke: true,
414
+ usage: 'connector [--account slug] [--json]',
415
+ query: ['account'],
416
+ summary: 'print the account\'s one connect URL for every MCP (the aggregate connector) and what it reaches',
417
+ example: ['connector', '--account', 'acme'],
418
+ },
419
+ { op: 'GET /api/v1/connector/connections', words: ['connector', 'connections'], query: ['account'], summary: 'your connections through the aggregate connector (one per approval), each revocable' },
420
+ { 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' },
421
+ { op: 'DELETE /api/v1/connector/connections', words: ['connector', 'revoke-all'], query: ['account'], summary: 'revoke every one of your aggregate connections in this account' },
422
+ {
423
+ op: 'GET /api/v1/marketplace',
424
+ bespoke: true,
425
+ usage: 'marketplace [--account slug]',
426
+ query: ['account'],
427
+ summary: 'whether a marketplace feed exists',
428
+ example: ['marketplace', '--account', 'acme'],
429
+ },
430
+ {
431
+ op: 'POST /api/v1/marketplace/token',
432
+ bespoke: true,
433
+ usage: 'marketplace rotate [--account slug]',
434
+ query: ['account'],
435
+ summary: 'mint (or rotate) the marketplace URL',
436
+ example: ['marketplace', 'rotate', '--account', 'acme'],
437
+ },
438
+ {
439
+ op: 'DELETE /api/v1/marketplace/token',
440
+ bespoke: true,
441
+ usage: 'marketplace revoke [--account slug]',
442
+ query: ['account'],
443
+ summary: 'revoke the marketplace URL',
444
+ example: ['marketplace', 'revoke', '--account', 'acme'],
445
+ },
446
+
447
+ // ── infrastructure (super-admin) ─────────────────────────────────────────
448
+ {
449
+ op: 'GET /api/v1/infra',
450
+ bespoke: true,
451
+ usage: 'infra [--days N] [--account slug] [--res-sort v] [--res-order v] [--res-q v] [--res-page v] [--res-per-page v]',
452
+ query: ['account', 'days', ...prefixed('res')],
453
+ summary: 'machines, the shared pool, resource totals',
454
+ example: ['infra', '--days', '7'],
455
+ },
456
+ {
457
+ op: 'POST /api/v1/infra/shared-machines/sync',
458
+ bespoke: true,
459
+ usage: 'infra sync',
460
+ summary: 'bring the pool\'s machine set in step with Fly',
461
+ example: ['infra', 'sync'],
462
+ },
463
+ {
464
+ op: 'POST /api/v1/infra/shared-machines/{id}/drain',
465
+ bespoke: true,
466
+ usage: 'infra drain <machine id>',
467
+ summary: 'take a pool machine out of placement',
468
+ example: ['infra', 'drain', 'm1'],
469
+ },
470
+ {
471
+ op: 'POST /api/v1/infra/shared-machines/{id}/undrain',
472
+ bespoke: true,
473
+ usage: 'infra undrain <machine id>',
474
+ summary: 'return a drained machine to placement',
475
+ example: ['infra', 'undrain', 'm1'],
476
+ },
477
+ ];
478
+
479
+ /** `perPage` → `per-page`: the flag spelling of a query or body field name. */
480
+ export function flagName(field) {
481
+ return field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
482
+ }
483
+
484
+ /** The path parameters of an operation, in the order they appear. */
485
+ export function pathParams(op) {
486
+ return [...op.split(' ')[1].matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
487
+ }
488
+
489
+ /** The usage line for a row — written out for a bespoke row, derived for a generic one. */
490
+ export function usageFor(row) {
491
+ if (row.bespoke) return row.usage;
492
+ const parts = [...row.words, ...(row.args ?? []).map((a) => `<${a}>`)];
493
+ for (const q of row.query ?? []) parts.push(`[--${flagName(q)} v]`);
494
+ for (const [field, type] of Object.entries(row.body ?? {})) {
495
+ parts.push(type === 'boolean' ? `[--${flagName(field)}]` : `[--${flagName(field)} ${type === 'json' ? 'json' : 'v'}]`);
496
+ }
497
+ if (row.body) parts.push('[--data <json|@file|->]');
498
+ return parts.join(' ');
499
+ }
500
+
501
+ /** The argv a generic row is exercised with: its words and a placeholder per positional. */
502
+ export function exampleFor(row) {
503
+ if (row.bespoke) return row.example;
504
+ return [...row.words, ...(row.args ?? []).map((a, i) => `${a}${i}`)];
505
+ }
506
+
507
+ /**
508
+ * The generic row an argv names, or undefined. The LONGEST word prefix wins
509
+ * (`connections secret rm` over `connections`), and among rows sharing words
510
+ * the one whose positional count matches (`tools` vs `tools <id>`). Called on
511
+ * the WORDS only — flags are parsed by the row once it is chosen, since
512
+ * which names are flags depends on the row.
513
+ *
514
+ * @param {string[]} argv
515
+ */
516
+ export function matchGeneric(argv) {
517
+ const leading = [];
518
+ for (const arg of argv) {
519
+ if (arg.startsWith('--')) break;
520
+ leading.push(arg);
521
+ }
522
+ const candidates = OPERATIONS.filter(
523
+ (row) => row.words !== undefined && row.words.length <= leading.length && row.words.every((w, i) => leading[i] === w),
524
+ );
525
+ if (candidates.length === 0) return undefined;
526
+ const longest = Math.max(...candidates.map((row) => row.words.length));
527
+ const tied = candidates.filter((row) => row.words.length === longest);
528
+ // A longer bespoke command wins over a shorter generic one, and is main()'s.
529
+ if (tied.some((row) => row.bespoke)) return undefined;
530
+ if (tied.length === 1) return tied[0];
531
+ // A shared prefix: decide by how many positionals follow the words. Counted
532
+ // over the whole argv's positionals would need the row's flag set to skip
533
+ // flag values, so count only the run before the first flag — which is where
534
+ // every usage line puts them.
535
+ const given = leading.length - longest;
536
+ return tied.find((row) => (row.args ?? []).length === given) ?? tied[0];
537
+ }
538
+
539
+ /**
540
+ * Help for what was typed: the one row a command names (`list --help`), else
541
+ * every row under the words given (`secret --help`), else everything. Matched
542
+ * on the leading WORDS of each row's usage line, so bespoke rows — which have
543
+ * no `words` — are found by what they print.
544
+ *
545
+ * @param {string[]} argv the command line with `--help` removed
546
+ */
547
+ export function helpFor(argv) {
548
+ const leading = [];
549
+ for (const arg of argv) {
550
+ if (arg.startsWith('-')) break;
551
+ leading.push(arg);
552
+ }
553
+ if (leading.length === 0) return helpText();
554
+ const row = matchGeneric(leading);
555
+ if (row && row.words.length === leading.length) return `usage: mcp-host ${usageFor(row)}`;
556
+ for (let k = leading.length; k > 0; k--) {
557
+ const prefix = leading.slice(0, k);
558
+ const lines = OPERATIONS.map(usageFor).filter((usage) => {
559
+ const words = usage.split(' ');
560
+ return prefix.every((w, i) => words[i] === w || (i === 1 && words[i] === `[${w}]`));
561
+ });
562
+ if (lines.length === 1) return `usage: mcp-host ${lines[0]}`;
563
+ if (lines.length > 1) return lines.map((u) => ` mcp-host ${u}`).join('\n');
564
+ }
565
+ return helpText();
566
+ }
567
+
568
+ /** The help text: every reachable operation, one line each. */
569
+ export function helpText() {
570
+ const lines = ['usage: mcp-host <command> [--insecure]', ''];
571
+ const rows = OPERATIONS.map((row) => [usageFor(row), row.summary]);
572
+ const width = Math.min(72, Math.max(...rows.map(([u]) => u.length)));
573
+ for (const [usage, summary] of rows) {
574
+ lines.push(usage.length > width ? ` ${usage}\n ${' '.repeat(width)} ${summary}` : ` ${usage.padEnd(width)} ${summary}`);
575
+ }
576
+ lines.push('', ' wizard register interactively', ' builder-key LEGACY: print HMAC(RUNNER_KEY, \'builder\')');
577
+ lines.push('', 'MCP_HOST_URL and MCP_HOST_ADMIN_TOKEN must be set. Output is JSON on stdout.');
578
+ return lines.join('\n');
579
+ }