@lanes-sh/link 0.3.2 → 0.4.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.
Files changed (68) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/identity.ts +29 -5
  24. package/src/cli/main.ts +37 -13
  25. package/src/cli/oauth.ts +89 -36
  26. package/src/cli/runtime/open.ts +13 -1
  27. package/src/cli/runtime/registry.ts +12 -0
  28. package/src/cli/selection.ts +12 -0
  29. package/src/cli/usage.ts +9 -1
  30. package/src/connectivity/auth/README.md +8 -1
  31. package/src/connectivity/auth/strategy/index.ts +128 -4
  32. package/src/connectivity/connector.ts +11 -0
  33. package/src/connectivity/index.ts +11 -1
  34. package/src/connectivity/manifest/auth.ts +19 -0
  35. package/src/connectivity/manifest/connector.ts +21 -0
  36. package/src/connectivity/manifest/primitives.ts +5 -1
  37. package/src/connectivity/manifest/provider.ts +30 -12
  38. package/src/connectivity/provider.ts +55 -0
  39. package/src/connectivity/transports/factory.ts +1 -0
  40. package/src/connectivity/transports/http/index.ts +73 -2
  41. package/src/dispatch/dispatch.ts +44 -5
  42. package/src/providers/bunq/hints.ts +43 -0
  43. package/src/providers/bunq/index.ts +87 -0
  44. package/src/providers/bunq/redact.ts +64 -0
  45. package/src/providers/bunq/specs/bunq.v1.json +864 -0
  46. package/src/providers/bunq/specs/vendor.ts +338 -0
  47. package/src/providers/bunq/strategy/handshake.ts +211 -0
  48. package/src/providers/bunq/strategy/index.ts +298 -0
  49. package/src/providers/bunq/strategy/keys.ts +72 -0
  50. package/src/providers/custom/index.ts +1 -6
  51. package/src/providers/custom/load.ts +56 -14
  52. package/src/providers/custom/template.ts +1 -1
  53. package/src/providers/discord/hints.ts +195 -0
  54. package/src/providers/discord/index.ts +121 -0
  55. package/src/providers/discord/redact.ts +99 -0
  56. package/src/providers/discord/specs/discord.v10.json +2333 -0
  57. package/src/providers/discord/specs/vendor.ts +164 -0
  58. package/src/providers/google/specs/vendor.ts +32 -317
  59. package/src/providers/index.ts +9 -0
  60. package/src/providers/reddit/index.ts +113 -0
  61. package/src/providers/reddit/oauth.ts +77 -0
  62. package/src/providers/reddit/redact.ts +33 -0
  63. package/src/providers/reddit/scopes.ts +27 -0
  64. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  65. package/src/providers/scopes.ts +2 -0
  66. package/src/providers/shared/openapi.ts +155 -0
  67. package/src/providers/shared/vendor-operations.ts +98 -0
  68. package/src/providers/shared/vendor-spec.ts +309 -0
package/README.md CHANGED
@@ -110,17 +110,25 @@ One command per account. Run it again to add a second mailbox, a second calendar
110
110
  | Linear | `lanes link connect linear` |
111
111
  | GitHub | `lanes link connect github` |
112
112
  | Slack | `lanes link connect slack` |
113
+ | Reddit | `lanes link connect reddit` |
114
+ | Discord | `lanes link connect discord` |
113
115
  | Gmail (IMAP, app password) | `lanes link connect gmail_imap` |
114
116
  | Gmail (Google MCP) | `lanes link connect gmail_mcp` |
115
117
  | Drive (Google MCP) | `lanes link connect drive_mcp` |
118
+ | bunq | `lanes link connect bunq` |
116
119
 
117
120
  Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
118
- Contacts together, because one app-specific password covers all three. Google and Slack need no
121
+ Contacts together, because one app-specific password covers all three. Reddit is the one that does
122
+ need an app of your own — it rate-limits per client id, so a shared client would mean strangers
123
+ spending your budget. Google and Slack need no
119
124
  OAuth client of your own: both authorise against the one Lanes operates, so there is no console to
120
125
  visit — for Google, add `--own-client` if you would rather register your own, or take a service
121
126
  account key or an app password over IMAP where you would rather nothing expired. And GitHub takes a
122
- token you paste rather than a browser sign-in, because it will not register a client for us; that
123
- is the one console visit left here.
127
+ token you paste rather than a browser sign-in, because it will not register a client for us. And
128
+ bunq — which wants a key from inside its app rather than a console at all — is the one that can move
129
+ money, and it says so: its payment tool executes immediately and is not reversible. Set a spending
130
+ limit on the API key while you are in there, and read
131
+ [docs/detailed/setup/bunq.md](docs/detailed/setup/bunq.md) before connecting it.
124
132
 
125
133
  Full guide — what each one gives your agent, what it needs, and adding your own:
126
134
  **[docs/connect.md](docs/connect.md)**.
@@ -56,8 +56,8 @@ to every future session.
56
56
  stops a write is policy on the endpoint:
57
57
 
58
58
  ```console
59
- $ lanes link policy deny memory.write
60
- $ lanes link policy list
59
+ $ lanes link policy deny memory.write --profile <name> --target <name>
60
+ $ lanes link policy list --profile <name>
61
61
  ```
62
62
 
63
63
  If you are running against a profile that grants writes, that is the owner's
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lanes-link
3
- description: Use when the user refers to their own accounts, knowledge, procedures, or secrets through Lanes Link — "check my mail", "what do I know about X", "remember this", "which profile am I in" — or asks to connect, register, or set up their Lanes Link MCP server with this agent. Also covers what to do when a Lanes Link call is refused, or when the endpoint is not running.
3
+ description: Use when the user refers to their own accounts, knowledge, procedures, or secrets through Lanes Link — "check my mail", "what do I know about X", "remember this", "which profile am I in" — or asks to connect, register, or set up their Lanes Link MCP server with this agent. Also covers operating the workspace from a shell — adding or removing a profile, checking what a target serves, deploying an endpoint or recovering a lost deployment — and what to do when a Lanes Link call is refused, or when the endpoint is not running.
4
4
  ---
5
5
 
6
6
  # Lanes Link
@@ -24,12 +24,32 @@ is listed first.** Quietly picking one crosses the line the profile exists to
24
24
  draw. There is no "current profile" to switch — the choice is made per call, and
25
25
  `lanes link profile list` shows what exists.
26
26
 
27
- **Every `lanes link` command names its profile and its target.** Both are required
28
- flags with no default, no environment variable, and nothing in a config file
29
- behind them, so a command missing either refuses rather than acting somewhere
30
- else. When you write one out for the owner, either fill both in or leave them as
31
- `<name>` for them to complete — never drop them. `lanes link target list
32
- --profile <name>` shows what a profile declares.
27
+ **What a command must be told is never inferred but it is not always both.**
28
+ Nothing resolves from an environment variable or a config default, so a command
29
+ missing what it needs refuses rather than acting somewhere else. Passing a flag a
30
+ command does not read is refused too, which makes "add both to be safe" its own
31
+ failure. Four levels:
32
+
33
+ - **Neither.** `lanes link profile list`, `lanes link mcp list`,
34
+ `lanes link version`.
35
+ - **`--profile` alone.** `lanes link check`, `lanes link config show`,
36
+ `lanes link policy list`, `lanes link target list --profile <name>`,
37
+ `lanes link identity list`. Each is target-independent — one declaration in the
38
+ YAML that applies wherever the profile runs.
39
+ - **`--target`, with the profiles derived from it.** `lanes link status`,
40
+ `lanes link deploy` and `lanes link sync targets` act on one endpoint serving
41
+ every profile that declares that target. `--profile` is accepted and *narrows*
42
+ the answer; it does not choose the subject.
43
+ - **Both.** Everything acting on one account: `lanes link connect`,
44
+ `lanes link token rotate`, `lanes link secrets set`, `lanes link policy allow`,
45
+ `lanes link memory list`, `lanes link mcp add`.
46
+
47
+ `lanes link profile add` and `lanes link profile remove` **reject** `--profile`.
48
+ Both name their profile positionally, so a flag naming a second one could only
49
+ disagree with it.
50
+
51
+ When you write a command out for the owner, fill in what that command needs or
52
+ leave it as `<name>` for them to complete — never drop a required one.
33
53
 
34
54
  A `connection` names an account within that profile. One profile may hold
35
55
  several of the same kind, and naming a connection belonging to a *different*
@@ -162,8 +182,101 @@ the line.
162
182
  **Never pass `--accept-broad-scopes` yourself.** When a provider asks for more than
163
183
  it needs, print the scopes and let the owner add the flag. Deciding that is theirs.
164
184
 
165
- A new connection is not served until the endpoint restarts, so a `setup_overview`
166
- straight after connecting will still not show it. Say so rather than retrying.
185
+ **A new connection is served at once; the tools you were handed are not.**
186
+ Connecting publishes the config to wherever that target's endpoint reads it and
187
+ asks the endpoint to re-read it, so a `setup_overview` straight after connecting
188
+ *does* show the account. What has not changed is the set of tools this session
189
+ was given when it connected — the endpoint does not announce that its tools
190
+ changed, so a capability for a freshly connected account is not callable until
191
+ the client reconnects. Say that, rather than reporting the connection as missing
192
+ or asking them to connect again.
193
+
194
+ One exception, and it is the one that matters here: the authenticator is built
195
+ once at boot and is not re-read, so a `lanes link token rotate` does need the
196
+ endpoint restarted before the new token opens anything.
197
+
198
+ ## Operating the workspace
199
+
200
+ If you have a shell, the commands that only *read* are yours to run without
201
+ asking: `lanes link status`, `lanes link check`, `lanes link plan`,
202
+ `lanes link doctor`, `lanes link profile list`, `lanes link target list`,
203
+ `lanes link target show`, `lanes link tools`, `lanes link config show` and
204
+ `lanes link audit tail`. None writes config, opens a browser, or costs anything,
205
+ and running one beats asking the owner to paste its output back. Give each what
206
+ its own level requires — they are not all the same, and the four levels are at
207
+ the top of this file.
208
+
209
+ **A command that writes runs `--dry-run` first, where it has one.** Show what it
210
+ reported and wait for an answer. `lanes link deploy`, `lanes link sync targets`,
211
+ `lanes link profile remove`, `lanes link secrets push` and `lanes link mcp add`
212
+ all take it. For a write with no dry run — `lanes link token rotate`,
213
+ `lanes link policy allow`, `lanes link secrets set` — say in one sentence what it
214
+ will change, then let them decide. Never a browser sign-in: that belongs to
215
+ whoever owns the account, as above.
216
+
217
+ **`--json` is not everywhere.** It parses on every command and is read by only
218
+ some, so one that ignores it prints its ordinary output and gives you nothing to
219
+ key on — do not treat the absence of JSON as a failure. `status`, `doctor`,
220
+ `tools`, `outputs`, `sync targets`, `target list`, `target show`, `profile list`,
221
+ `connect` and `setup plan` implement it. `deploy`, `check`, `plan`,
222
+ `config show`, `audit tail` and every `mcp` subcommand do not.
223
+
224
+ **A profile is created and removed, never switched.** `lanes link profile add
225
+ <name>` writes a new one and takes `--target <name>`, repeated once per target it
226
+ should declare. `lanes link profile remove <name>` takes `--dry-run` and then
227
+ `--yes`; given a `--target` it decommissions that one target's stores and leaves
228
+ the profile file in place. Neither reads `--profile`.
229
+
230
+ There is no current profile and no default target. `lanes link profile default`
231
+ and `lanes link target use` are gone and now refuse with an explanation — if you
232
+ meet one of those refusals, it is not a broken install, and there is no
233
+ replacement to find. The choice is made per command, on purpose.
234
+
235
+ ## Deploying, and what it decides
236
+
237
+ `lanes link deploy` builds an image and rolls a revision. Its subject is a
238
+ **target**, and the profiles behind it are every profile declaring that target,
239
+ so one deploy serves all of them — there is no per-profile deploy to run and no
240
+ reason to loop over them.
241
+
242
+ **Always `--dry-run` first, and show what it printed.** It creates cloud
243
+ resources that cost money, it implements no `--json` to inspect instead, and that
244
+ plan is the only place the consequences are visible while they are still
245
+ avoidable.
246
+
247
+ Two things it refuses to guess, and both are the owner's to answer:
248
+
249
+ - **Whose bearer token opens the endpoint.** One token reaches every profile
250
+ behind that target, so this decides who gets in. With several candidates and
251
+ nothing recorded, it refuses and prints the command that names one.
252
+ - **A first deploy.** A target no profile declares yet has no set to derive from,
253
+ so `--profile` is required there. It may be repeated, and the first one named
254
+ is the primary.
255
+
256
+ **Never pass `--yes`, `--non-interactive`, `--access public` or
257
+ `--service-account` yourself.** Each settles a question about who can reach their
258
+ accounts. Print what the flag would decide and let them add it — the same rule
259
+ this file already applies to `--accept-broad-scopes`.
260
+
261
+ Deploying is how new code reaches the endpoint. It is not how an account gets
262
+ connected and not how a config change lands; both of those publish themselves.
263
+
264
+ ## When the workspace and the endpoint disagree
265
+
266
+ A deployment records where it lives, and a workspace can lose that record — a new
267
+ machine, a reinstall, a profile file restored from something older. The endpoint
268
+ is still serving; what went missing is the config that describes it. The symptom
269
+ is a `lanes link status` that reports nothing deployed for a target you know is
270
+ up.
271
+
272
+ `lanes link sync targets` reconciles the two. `--discover` looks for a deployment
273
+ the workspace has no record of, `--from <location>` names one directly, and
274
+ `--dry-run` reports what it would merge without merging it. Run the dry run and
275
+ show it.
276
+
277
+ **`--prefer local` or `--prefer remote` is their answer, not yours.** It decides
278
+ which side wins where the two disagree, and the losing value is the one nobody
279
+ was asked about. Report what differs and let them pick.
167
280
 
168
281
  ## Registering it, and re-registering it
169
282
 
@@ -192,7 +305,7 @@ substitution form. If you have already printed one by accident, say so and offer
192
305
  Prefer `lanes link mcp add --profile <name> --target <name>` to writing the command yourself: it checks the
193
306
  endpoint is reachable, refuses to silently shadow an existing registration, and
194
307
  cannot mistype the token. For a harness it does not know, take the command from
195
- `lanes link outputs` rather than writing it blind — that command checks whether
308
+ `lanes link outputs --profile <name> --target <name>` rather than writing it blind — that command checks whether
196
309
  `lanes` resolves on this machine and prints a longer working form if it does
197
310
  not, where guessing gives you an empty substitution, a `Bearer ` header, and a
198
311
  401 that reads as a bad token.
@@ -207,9 +320,20 @@ export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --target
207
320
  One registration covers every profile. Do not add one per profile; they share a
208
321
  URL and a token.
209
322
 
323
+ `lanes link mcp list` needs neither flag and reports whether the registration and
324
+ this document are current, out of date, or absent. That is the cheap first
325
+ question when behaviour does not match what this file describes — an out-of-date
326
+ copy means the rules you are reading are not the ones that shipped.
327
+
328
+ Claude Desktop cannot be handed a URL, so it spawns the endpoint over stdio
329
+ instead. That one is named in the client's own config file rather than registered
330
+ by a command, as `lanes link mcp stdio --profile <name> --target <name>`; both
331
+ flags are required, and nothing may be written to stdout.
332
+
210
333
  ## When it is not running
211
334
 
212
- `lanes link start` runs in the foreground and serves until stopped. Tell the
335
+ `lanes link start --profile <name> --target <name>` runs in the foreground and
336
+ serves until stopped. Tell the
213
337
  user the command rather than backgrounding it silently on their behalf.
214
338
  Registration works while it is down — the harness simply cannot reach it yet,
215
339
  and the first symptom is a failed call much later.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -49,6 +49,8 @@
49
49
  "typecheck": "tsc --noEmit",
50
50
  "lanes": "bun run ./src/cli/lanes.ts",
51
51
  "audit": "bun pm scan",
52
+ "vendor:bunq": "bun run ./src/providers/bunq/specs/vendor.ts",
53
+ "vendor:discord": "bun run ./src/providers/discord/specs/vendor.ts",
52
54
  "vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
53
55
  },
54
56
  "imports": {
package/src/cli/argv.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { GlobalFlags } from './runtime.ts';
2
2
  import type { OwnerFlags } from './commands/owner.ts';
3
3
  import type { KnowledgeFlags } from './commands/knowledge.ts';
4
+ import type { CustomFlags } from './commands/connect/custom/spec.ts';
4
5
 
5
6
  /**
6
7
  * Turning argv into a command path and a flag bag.
@@ -142,3 +143,54 @@ export function knowledgeFlags(flags: Flags): KnowledgeFlags {
142
143
  json: flags['json'] === true,
143
144
  };
144
145
  }
146
+
147
+ /**
148
+ * `lanes link connect custom`'s flags.
149
+ *
150
+ * Here for the reason `ownerFlags` and `knowledgeFlags` are: the kebab-case
151
+ * spellings belong in the one place that parses argv. The list flags go through
152
+ * `all(argv, …)` rather than through `flags`, because `parseArgv` keeps only the
153
+ * last value of a repeated flag — so `--scopes a --scopes b` would silently
154
+ * become `b`, and a scope quietly dropped is a token that works until it does
155
+ * not.
156
+ *
157
+ * Which of these are read at all depends on the connectivity type and the
158
+ * credential type chosen; that is `custom/spec.ts`'s to know. This only spells
159
+ * them.
160
+ */
161
+ export function customFlags(flags: Flags, argv: readonly string[]): CustomFlags {
162
+ return {
163
+ connector: text(flags, 'connector'),
164
+ auth: text(flags, 'auth'),
165
+ name: text(flags, 'name'),
166
+ description: text(flags, 'description'),
167
+ endpoint: text(flags, 'endpoint'),
168
+ baseUrl: text(flags, 'base-url'),
169
+ openapi: text(flags, 'openapi'),
170
+ operations: all(argv, 'operations'),
171
+ service: text(flags, 'service'),
172
+ host: text(flags, 'host'),
173
+ port: text(flags, 'port'),
174
+ smtpHost: text(flags, 'smtp-host'),
175
+ smtpPort: text(flags, 'smtp-port'),
176
+ root: text(flags, 'root'),
177
+ exclude: all(argv, 'exclude'),
178
+ header: all(argv, 'header'),
179
+ authHeader: text(flags, 'auth-header'),
180
+ authQuery: text(flags, 'auth-query'),
181
+ scopes: all(argv, 'scopes'),
182
+ authorizeUrl: text(flags, 'authorize-url'),
183
+ tokenUrl: text(flags, 'token-url'),
184
+ clientApp: text(flags, 'client-app'),
185
+ registration: text(flags, 'registration'),
186
+ redirectUri: text(flags, 'redirect-uri'),
187
+ authorizeParam: all(argv, 'authorize-param'),
188
+ strategy: text(flags, 'strategy'),
189
+ strategyOption: all(argv, 'strategy-option'),
190
+ identityUrl: text(flags, 'identity-url'),
191
+ identityField: text(flags, 'identity-field'),
192
+ setupDocs: text(flags, 'setup-docs'),
193
+ replaceManifest: flags['replace-manifest'] === true,
194
+ yes: flags['yes'] === true,
195
+ };
196
+ }
@@ -283,6 +283,11 @@ async function authoriseDirect(input: {
283
283
  ...(client.kind === 'brokered' && client.config.redirectUri
284
284
  ? { relayRedirect: client.config.redirectUri }
285
285
  : {}),
286
+ // The other spelling of the same need, for a vendor that takes a loopback
287
+ // redirect but matches it exactly. The manifest owns this URL because the
288
+ // vendor's console does — it has to be the string registered there, and
289
+ // `defineProvider` refuses a manifest declaring both this and a broker.
290
+ ...(manifest.auth.redirect_uri ? { fixedRedirect: manifest.auth.redirect_uri } : {}),
286
291
  scopes,
287
292
  connectionLabel: manifest.name,
288
293
  ...(manifest.auth.authorize_params
@@ -0,0 +1,167 @@
1
+ import { style } from '../../../output.ts';
2
+ import type { Prompter } from '../../../prompt.ts';
3
+ import { parseAuthMethod, parseConnectorKind, refuseIllegalPair } from './derive.ts';
4
+ import {
5
+ AUTH_FIELDS,
6
+ AUTH_METHODS,
7
+ CONNECTOR_FIELDS,
8
+ CONNECTOR_KINDS,
9
+ camel,
10
+ titleCase,
11
+ type CustomAnswers,
12
+ type CustomFlags,
13
+ type FieldSpec,
14
+ } from './spec.ts';
15
+
16
+ /**
17
+ * Collecting what the flags did not say.
18
+ *
19
+ * Every value has a flag, so the whole command is scriptable — but the fields
20
+ * differ per connectivity type and per credential type, and nobody holds thirty
21
+ * flag names in their head to declare one provider. So a missing required value
22
+ * is asked for, in a fixed order, and the answers are indistinguishable from
23
+ * having been typed.
24
+ *
25
+ * Under `--non-interactive` nothing is asked and *everything* missing is named
26
+ * at once, with the command to re-run. One round trip rather than one refusal
27
+ * per flag, because the caller there is usually a script or an agent and each
28
+ * refusal costs it a whole run.
29
+ */
30
+
31
+ /** Named, not asked: nothing can be collected until the two lists are picked. */
32
+ export interface Blocked {
33
+ readonly missing: readonly string[];
34
+ readonly command: string;
35
+ }
36
+
37
+ export async function collect(
38
+ id: string,
39
+ flags: CustomFlags,
40
+ prompter: Prompter,
41
+ /** How to spell the re-run, when there is nothing to ask. */
42
+ commandFor: (missing: readonly string[]) => string,
43
+ ): Promise<CustomAnswers | Blocked> {
44
+ const missing: string[] = [];
45
+
46
+ const connector = flags.connector
47
+ ? parseConnectorKind(flags.connector)
48
+ : prompter.interactive
49
+ ? parseConnectorKind(await choose(prompter, 'How is this service reached?', CONNECTOR_KINDS))
50
+ : (missing.push('connector'), undefined);
51
+
52
+ const auth = flags.auth
53
+ ? parseAuthMethod(flags.auth)
54
+ : prompter.interactive
55
+ ? parseAuthMethod(await choose(prompter, 'How does it authenticate?', AUTH_METHODS))
56
+ : (missing.push('auth'), undefined);
57
+
58
+ // Which fields exist at all depends on the two answers above, so a
59
+ // non-interactive run missing either cannot list the rest yet. Saying so beats
60
+ // guessing at a list that would be wrong.
61
+ if (!connector || !auth) return { missing, command: commandFor(missing) };
62
+
63
+ // Before a single field is asked for. The pair is decided by the two answers
64
+ // above, so a combination that cannot work is knowable here — and asking six
65
+ // questions about a mailbox that will be refused for its credential type is
66
+ // worse than refusing straight away.
67
+ refuseIllegalPair(connector, auth);
68
+
69
+ const fields = [...CONNECTOR_FIELDS[connector], ...AUTH_FIELDS[auth], ...EXTRA];
70
+ const values: Record<string, string | readonly string[]> = {};
71
+
72
+ for (const field of fields) {
73
+ const given = flags[camel(field.flag) as keyof CustomFlags];
74
+
75
+ if (Array.isArray(given)) {
76
+ if (given.length > 0) values[field.flag] = given;
77
+ continue;
78
+ }
79
+ if (typeof given === 'string' && given.length > 0) {
80
+ values[field.flag] = given;
81
+ continue;
82
+ }
83
+ if (!field.required) continue;
84
+
85
+ if (!prompter.interactive) {
86
+ missing.push(field.flag);
87
+ continue;
88
+ }
89
+
90
+ const answer = field.choices
91
+ ? await choose(prompter, field.label, field.choices)
92
+ : await askFor(prompter, field);
93
+
94
+ if (answer.length > 0) values[field.flag] = answer;
95
+ else missing.push(field.flag);
96
+ }
97
+
98
+ if (missing.length > 0) return { missing, command: commandFor(missing) };
99
+
100
+ const name = flags.name ?? (prompter.interactive ? await askName(prompter, id) : titleCase(id));
101
+
102
+ return {
103
+ id,
104
+ name,
105
+ ...(flags.description ? { description: flags.description } : {}),
106
+ connector,
107
+ auth,
108
+ values,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Fields that belong to no single kind.
114
+ *
115
+ * All optional, so a non-interactive run is never blocked on one — and none is
116
+ * prompted for, because a question nobody needs to answer is worse than a flag
117
+ * nobody types. `derive.ts` refuses the combinations that do not work.
118
+ */
119
+ const EXTRA: readonly FieldSpec[] = [
120
+ { flag: 'identity-url', label: 'Identity URL', required: false },
121
+ { flag: 'identity-field', label: 'Identity field', required: false },
122
+ { flag: 'setup-docs', label: 'Where to get the credential', required: false },
123
+ ];
124
+
125
+ async function askFor(prompter: Prompter, field: FieldSpec): Promise<string> {
126
+ if (field.hint) print(style.dim(` ${field.hint}`));
127
+ return (await prompter.ask(`${field.label}: `)).trim();
128
+ }
129
+
130
+ async function askName(prompter: Prompter, id: string): Promise<string> {
131
+ const suggested = titleCase(id);
132
+ const answer = (await prompter.ask(`Display name [${suggested}]: `)).trim();
133
+ return answer.length > 0 ? answer : suggested;
134
+ }
135
+
136
+ /**
137
+ * A closed set, offered as a numbered list.
138
+ *
139
+ * The same shape `chooseAuthMethod` uses for a provider with more than one way
140
+ * in, for the same reason: these are the members of a union, and typing one from
141
+ * memory is how somebody discovers a name is `api-key` and not `apikey` by
142
+ * being refused.
143
+ */
144
+ async function choose(
145
+ prompter: Prompter,
146
+ question: string,
147
+ options: readonly string[],
148
+ ): Promise<string> {
149
+ print('');
150
+ print(question);
151
+ options.forEach((option, index) => print(` ${index + 1}. ${option}`));
152
+
153
+ const answer = (await prompter.ask(`Choose 1-${options.length}: `)).trim();
154
+ const index = Number(answer);
155
+
156
+ // A name is accepted too. Somebody who already knows it should not have to
157
+ // count, and the numbers exist for somebody who does not.
158
+ if (options.includes(answer)) return answer;
159
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1]!;
160
+
161
+ throw new Error(`"${answer}" is not one of: ${options.join(', ')}.`);
162
+ }
163
+
164
+ /** Prompts and their hints go to stderr, so `--json` leaves stdout clean. */
165
+ function print(line: string): void {
166
+ process.stderr.write(`${line}\n`);
167
+ }
@@ -0,0 +1,143 @@
1
+ import { AUTH_KIND, type CustomAnswers } from './spec.ts';
2
+ import { many, one, pairs } from './values.ts';
3
+
4
+ /**
5
+ * The credential block, and the OAuth rules the schema does not carry.
6
+ *
7
+ * Split from `derive.ts` because it fails for a different reason. That file is
8
+ * about a connectivity type's fields, which are either given or defaulted; this
9
+ * one is about arrangements that *validate* and then cannot run — an
10
+ * authorization server with nowhere to send the browser, a client nobody
11
+ * registered, a header the transport will not read. Each of those surfaces after
12
+ * a credential has been stored or a consent screen approved, so each is refused
13
+ * here.
14
+ */
15
+
16
+ export function authBlock(answers: CustomAnswers): Record<string, unknown> {
17
+ const method = answers.auth;
18
+ const kind = AUTH_KIND[method];
19
+ const header = one(answers, 'auth-header');
20
+ const query = one(answers, 'auth-query');
21
+
22
+ if (method === 'none' || method === 'basic') return { kind };
23
+
24
+ if (method === 'bearer') {
25
+ if (header && answers.connector === 'mcp') {
26
+ throw new Error(
27
+ 'An mcp connector always sends its token as "Authorization: Bearer", so --auth-header ' +
28
+ `("${header}") could not be honoured — the transport never reads the resolved credential, ` +
29
+ 'only the token, and the header would be dropped in silence.\n' +
30
+ ' Drop the flag, or reach this service with --connector http.',
31
+ );
32
+ }
33
+ return { kind, ...(header ? { header } : {}) };
34
+ }
35
+
36
+ if (method === 'header') {
37
+ if (query) {
38
+ throw new Error(
39
+ '--auth-query is only meaningful for --auth api-key, which may put its key in the query ' +
40
+ 'string. A "header" credential is sent in a header of its own and nothing forwards a query ' +
41
+ 'parameter, so this would be dropped in silence.',
42
+ );
43
+ }
44
+ return { kind, header };
45
+ }
46
+
47
+ if (method === 'strategy') {
48
+ // Whether the name resolves is the registry's to answer, not this file's: a
49
+ // strategy travels on a provider's definition, and `strategyFor` looks first
50
+ // at this manifest's own and then at every other registered provider's.
51
+ // `refuseStrategy` is what says a name reaches nothing, and it can list what
52
+ // does — which is why this does not try to.
53
+ const named = one(answers, 'strategy');
54
+ const options = pairs(answers, 'strategy-option');
55
+
56
+ return {
57
+ kind,
58
+ strategy: named,
59
+ ...(options ? { options } : {}),
60
+ };
61
+ }
62
+
63
+ if (method === 'api-key') {
64
+ if (header && query) {
65
+ throw new Error(
66
+ '--auth-header and --auth-query contradict: a key goes in a header or in the query string, ' +
67
+ 'and declaring both leaves which one is sent up to whichever the transport reads last.',
68
+ );
69
+ }
70
+ return { kind, ...(header ? { header } : {}), ...(query ? { query } : {}) };
71
+ }
72
+
73
+ return oauthBlock(answers);
74
+ }
75
+
76
+ /**
77
+ * The one credential type with rules the schema does not carry.
78
+ *
79
+ * Each of these produces a manifest `defineProvider` accepts and `authorise`
80
+ * cannot run, which is worse than an invalid one: the operator finds out after
81
+ * approving a consent screen, or not until the first call.
82
+ */
83
+ function oauthBlock(answers: CustomAnswers): Record<string, unknown> {
84
+ const scopes = many(answers, 'scopes');
85
+ const authorizeUrl = one(answers, 'authorize-url');
86
+ const tokenUrl = one(answers, 'token-url');
87
+ const app = one(answers, 'client-app');
88
+ const declared = one(answers, 'registration');
89
+ const redirect = one(answers, 'redirect-uri');
90
+ const authorizeParams = pairs(answers, 'authorize-param');
91
+
92
+ if (Boolean(authorizeUrl) !== Boolean(tokenUrl)) {
93
+ throw new Error(
94
+ 'OAuth endpoints are declared together or not at all. `authorise` takes the direct path only ' +
95
+ 'when both are present, so half a pair is ignored on an mcp connector and fatal on an http ' +
96
+ 'one.\n Add the missing one of --authorize-url / --token-url, or drop both.',
97
+ );
98
+ }
99
+
100
+ // A REST API has no MCP metadata document to discover from, so a client has
101
+ // to come from somewhere the manifest names.
102
+ const registration =
103
+ declared ?? (answers.connector === 'mcp' && !authorizeUrl && !app ? 'dynamic' : 'manual');
104
+
105
+ if (registration === 'dynamic' && answers.connector === 'http') {
106
+ throw new Error(
107
+ 'Dynamic client registration is something an authorization server offers, and a REST API ' +
108
+ 'publishes no registration endpoint — there would be no client to authorise with.\n' +
109
+ ' Register a client with the vendor and drop --registration, which then defaults to manual.',
110
+ );
111
+ }
112
+
113
+ if (registration === 'dynamic' && authorizeUrl) {
114
+ throw new Error(
115
+ 'Declaring both endpoints is what takes an mcp connector off the SDK\'s own OAuth path and onto ' +
116
+ 'ours, and ours needs a client to present (ADR-040). Registration cannot be dynamic there.\n' +
117
+ ' Drop --registration dynamic, or drop the two endpoint URLs and let the SDK register.',
118
+ );
119
+ }
120
+
121
+ if (registration === 'manual' && answers.connector === 'http' && !authorizeUrl) {
122
+ throw new Error(
123
+ 'An http connector has no metadata document to discover an authorization server from: a REST ' +
124
+ 'API is a base URL, and where consent happens is not something it announces.\n' +
125
+ ' Add --authorize-url and --token-url.',
126
+ );
127
+ }
128
+
129
+ return {
130
+ kind: 'oauth',
131
+ registration,
132
+ ...(registration === 'manual' ? { app: app ?? answers.id } : {}),
133
+ scopes,
134
+ ...(authorizeUrl ? { authorize_url: authorizeUrl } : {}),
135
+ ...(tokenUrl ? { token_url: tokenUrl } : {}),
136
+ ...(redirect ? { redirect_uri: redirect } : {}),
137
+ // Google needs `access_type=offline` and `prompt=consent`, without which it
138
+ // returns an access token and no refresh token — so the connection works for
139
+ // an hour and then dies, which is a miserable thing to debug. Any vendor can
140
+ // have one of these, and nothing but the vendor's own docs will say so.
141
+ ...(authorizeParams ? { authorize_params: authorizeParams } : {}),
142
+ };
143
+ }