@lanes-sh/link 0.3.2 → 0.4.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.
Files changed (69) 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/commands/mcp/list.ts +123 -29
  24. package/src/cli/identity.ts +29 -5
  25. package/src/cli/main.ts +42 -14
  26. package/src/cli/oauth.ts +89 -36
  27. package/src/cli/runtime/open.ts +13 -1
  28. package/src/cli/runtime/registry.ts +12 -0
  29. package/src/cli/selection.ts +12 -0
  30. package/src/cli/usage.ts +9 -1
  31. package/src/connectivity/auth/README.md +8 -1
  32. package/src/connectivity/auth/strategy/index.ts +128 -4
  33. package/src/connectivity/connector.ts +11 -0
  34. package/src/connectivity/index.ts +11 -1
  35. package/src/connectivity/manifest/auth.ts +19 -0
  36. package/src/connectivity/manifest/connector.ts +21 -0
  37. package/src/connectivity/manifest/primitives.ts +5 -1
  38. package/src/connectivity/manifest/provider.ts +30 -12
  39. package/src/connectivity/provider.ts +55 -0
  40. package/src/connectivity/transports/factory.ts +1 -0
  41. package/src/connectivity/transports/http/index.ts +73 -2
  42. package/src/dispatch/dispatch.ts +44 -5
  43. package/src/providers/bunq/hints.ts +45 -0
  44. package/src/providers/bunq/index.ts +87 -0
  45. package/src/providers/bunq/redact.ts +75 -0
  46. package/src/providers/bunq/specs/bunq.v1.json +883 -0
  47. package/src/providers/bunq/specs/vendor.ts +396 -0
  48. package/src/providers/bunq/strategy/handshake.ts +211 -0
  49. package/src/providers/bunq/strategy/index.ts +298 -0
  50. package/src/providers/bunq/strategy/keys.ts +72 -0
  51. package/src/providers/custom/index.ts +1 -6
  52. package/src/providers/custom/load.ts +56 -14
  53. package/src/providers/custom/template.ts +1 -1
  54. package/src/providers/discord/hints.ts +195 -0
  55. package/src/providers/discord/index.ts +121 -0
  56. package/src/providers/discord/redact.ts +99 -0
  57. package/src/providers/discord/specs/discord.v10.json +2333 -0
  58. package/src/providers/discord/specs/vendor.ts +164 -0
  59. package/src/providers/google/specs/vendor.ts +32 -317
  60. package/src/providers/index.ts +9 -0
  61. package/src/providers/reddit/index.ts +113 -0
  62. package/src/providers/reddit/oauth.ts +77 -0
  63. package/src/providers/reddit/redact.ts +33 -0
  64. package/src/providers/reddit/scopes.ts +27 -0
  65. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  66. package/src/providers/scopes.ts +2 -0
  67. package/src/providers/shared/openapi.ts +155 -0
  68. package/src/providers/shared/vendor-operations.ts +179 -0
  69. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -27,6 +27,7 @@ export async function settleIdentity(input: {
27
27
  credentials: SecretStore;
28
28
  registry: { manifest(id: string): ProviderManifest | undefined };
29
29
  connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
30
+ authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
30
31
  };
31
32
  prompter?: Prompter;
32
33
  }): Promise<{ connectionId: string; account: string }> {
@@ -51,8 +52,24 @@ export async function settleIdentity(input: {
51
52
  if (!account) {
52
53
  const token = () => bearerTokenAsStored(manifest, provisionalId, runtime.credentials);
53
54
 
55
+ // Only where `accessToken` cannot answer: `requestAuthorizer` goes through
56
+ // `credentialResolver`, which may *refresh* an OAuth token, and
57
+ // `bearerTokenAsStored` is deliberately the connect-time variant that does
58
+ // not. Leaving oauth and bearer on the token keeps refresh behaviour
59
+ // exactly as it was; these three had no working path at all.
60
+ const carriesItsOwnHeader =
61
+ manifest.auth.kind === 'api_key' ||
62
+ manifest.auth.kind === 'header' ||
63
+ manifest.auth.kind === 'basic';
64
+
54
65
  account = await resolveAccount(manifest, {
55
66
  accessToken: token,
67
+ ...(carriesItsOwnHeader
68
+ ? {
69
+ authorize: (request: Request) =>
70
+ runtime.authorizeRequest(manifest.id, provisionalId, request),
71
+ }
72
+ : {}),
56
73
  // A protocol that authenticates by username has nothing to GET and no
57
74
  // tool to call — it knows, once the server has accepted the login.
58
75
  identify: async () =>
@@ -145,7 +145,15 @@ export async function ensureStaticCredential(input: {
145
145
  const { manifest, connectionId, credentials, replace, provisional } = input;
146
146
  const prompter = input.prompter ?? terminalPrompter;
147
147
  const auth = manifest.auth;
148
- if (auth.kind === 'none' || auth.kind === 'oauth' || auth.kind === 'strategy') return;
148
+ // `strategy` used to be excluded here, from when no strategy existed and the
149
+ // kind was unreachable. It is exactly the wrong exclusion: a strategy
150
+ // provider's credential is *more* of a pasted value than most — bunq's API
151
+ // key is the input its handshake runs on — and skipping the prompt left
152
+ // `lanes link connect bunq` storing nothing and then failing inside the
153
+ // handshake with "no API key was stored", on the interactive path the setup
154
+ // documentation describes. Everything below is generic: the ref derives, the
155
+ // prompts come from the manifest.
156
+ if (auth.kind === 'none' || auth.kind === 'oauth') return;
149
157
 
150
158
  const ref = credentialRefForConnection(manifest, connectionId)!;
151
159
  const stored = await credentials.has(ref);
@@ -0,0 +1,87 @@
1
+ import { credentialRefForConnection, strategyContextFrom, strategyFor } from '#connectivity';
2
+ import type { ProviderManifest } from '#connectivity';
3
+ import { createScopedStore, scopeNamespace } from '#dispatch';
4
+ import { scopeSecrets } from '#secrets';
5
+ import type { SecretStore } from '#secrets';
6
+ import type { RuntimeState } from '#stores/state';
7
+ import type { ProviderRegistry } from '#registry';
8
+ import { progress, style } from '../../output.ts';
9
+
10
+ /**
11
+ * The handshake a strategy provider needs before it can be called at all.
12
+ *
13
+ * Ordinary providers finish authenticating the moment the operator pastes
14
+ * something: the value *is* the credential. A strategy provider has only half
15
+ * of one at that point — bunq's API key is the input to an installation, not a
16
+ * token — so this runs the vendor's own setup between storing what was pasted
17
+ * and asking whose account it is.
18
+ *
19
+ * **Why it sits where it does in `connect`.** After the credential is stored,
20
+ * because the handshake's input is what was pasted. Before identity is settled
21
+ * and anything is written to the config, because a key the vendor rejects
22
+ * should fail here — with the vendor's own message — rather than leaving a
23
+ * connection row describing an account that cannot be reached. It runs under
24
+ * the *provisional* connection id for the same reason every other step does:
25
+ * the real one is not known yet, and `connect` moves the credential when it is.
26
+ *
27
+ * Here rather than in `index.ts` because that file is at its size budget, and
28
+ * because this is one self-contained step: everything it needs is a manifest
29
+ * and a runtime, and everything it produces is in the credential store.
30
+ */
31
+ export async function runStrategySetup(
32
+ manifest: ProviderManifest,
33
+ /** The provisional id. `connect` renames the connection afterwards and the credential follows. */
34
+ connectionId: string,
35
+ /** Structurally the CLI `Runtime`, named as the parts this actually reaches. */
36
+ runtime: {
37
+ readonly registry: ProviderRegistry;
38
+ readonly credentials: SecretStore;
39
+ readonly state: RuntimeState;
40
+ readonly resolution: { readonly profile: string };
41
+ },
42
+ ): Promise<void> {
43
+ const { registry, credentials, state } = runtime;
44
+ const profile = runtime.resolution.profile;
45
+ if (manifest.auth.kind !== 'strategy') return;
46
+
47
+ const strategy = strategyFor(manifest, registry);
48
+ if (!strategy.setup) return;
49
+
50
+ // The one ref this connection may read, which is also the one the pasted
51
+ // value landed in and the one the handshake will rewrite.
52
+ const ref = credentialRefForConnection(manifest, connectionId)!;
53
+
54
+ const context = strategyContextFrom({
55
+ source: {
56
+ credentials: scopeSecrets(credentials, [ref]),
57
+ state: createScopedStore(state, scopeNamespace(manifest.id, connectionId)),
58
+ log: {
59
+ debug: () => {},
60
+ info: (message) => progress(style.dim(` ${message}`)),
61
+ warn: (message) => progress(style.dim(` ${message}`)),
62
+ error: (message) => progress(style.dim(` ${message}`)),
63
+ },
64
+ },
65
+ manifest,
66
+ connectionId,
67
+ profile,
68
+ // Writable *only* here — the dispatch path passes nothing, so a per-request
69
+ // handshake cannot persist anything.
70
+ //
71
+ // Scoped to the same single ref the reads are. Handing over the raw store
72
+ // would leave the write side of this boundary open while the read side is
73
+ // shut, which is the asymmetry that makes a boundary decorative: a strategy
74
+ // could not *read* `google/main` and could quietly overwrite it.
75
+ write: async (reference, value) => {
76
+ if (reference !== ref) {
77
+ throw new Error(
78
+ `The ${manifest.name} strategy tried to write ${reference}, which is not its connection's credential (${ref}).`,
79
+ );
80
+ }
81
+ await credentials.set(reference, value);
82
+ },
83
+ });
84
+
85
+ progress(style.dim(` Registering this device with ${manifest.name}…`));
86
+ await strategy.setup(context);
87
+ }
@@ -0,0 +1,41 @@
1
+ import { layout } from '#profile';
2
+ import type { ProviderRegistry } from '#registry';
3
+ import { PROGRAM } from '../../usage.ts';
4
+
5
+ /**
6
+ * What to say when a name resolves to no provider.
7
+ *
8
+ * The one message that has to teach something, because a misspelling and a
9
+ * service nobody has integrated arrive here identically and want opposite
10
+ * answers. So it prints both lists — what is shipped, and what this profile has
11
+ * added — and then the command that adds another.
12
+ *
13
+ * It named `<root>/providers/` for a long time, which is not where manifests
14
+ * live and never was. The path comes from `layout` now, like every other
15
+ * profile-owned path, and the "add your own" line is printed whether or not the
16
+ * operator already has one: showing it only to somebody with none meant hiding
17
+ * it from everybody except the one person who could not yet know it existed.
18
+ */
19
+ export function unknownProvider(input: {
20
+ readonly providerId: string;
21
+ readonly registry: ProviderRegistry;
22
+ readonly workspaceRoot: string;
23
+ readonly profile: string;
24
+ readonly target: string;
25
+ }): Error {
26
+ const available = input.registry.list();
27
+ const builtin = available.filter((c) => c.origin === 'builtin').map((c) => c.manifest.id);
28
+ const yours = available.filter((c) => c.origin === 'workspace').map((c) => c.manifest.id);
29
+
30
+ const selection = `--profile ${input.profile} --target ${input.target}`;
31
+ const directory = `${input.workspaceRoot}/${layout.providers(input.profile)}`;
32
+
33
+ return new Error(
34
+ `Unknown provider "${input.providerId}".\n` +
35
+ ` built in: ${builtin.join(', ')}\n` +
36
+ (yours.length > 0 ? ` yours: ${yours.join(', ')}\n` : '') +
37
+ ` add your own: ${PROGRAM} connect custom ${input.providerId}` +
38
+ ` --connector <kind> --auth <method> ${selection}\n` +
39
+ ` or a manifest in ${directory}\n`,
40
+ );
41
+ }
@@ -1,8 +1,46 @@
1
- import { heading, print, style } from '../../output.ts';
1
+ import { emit, heading, print, style } from '../../output.ts';
2
2
  import { assetState, plannedAssets, readAsset } from './assets.ts';
3
3
  import { HARNESSES, type Harness } from './harnesses.ts';
4
4
  import { exists } from './register.ts';
5
5
 
6
+ /** Why a document this harness can hold is not what we ship, if it is not. */
7
+ export type DocumentState = 'current' | 'stale' | 'missing' | 'unreadable';
8
+
9
+ export interface ListedDocument {
10
+ readonly label: string;
11
+ readonly path: string;
12
+ readonly state: DocumentState;
13
+ /** Only for `unreadable` — what went wrong reading the bundled copy. */
14
+ readonly detail?: string;
15
+ }
16
+
17
+ export interface ListedHarness {
18
+ readonly id: string;
19
+ readonly label: string;
20
+ readonly installed: boolean;
21
+ /** The resolved binary, so a caller can see *which* claude answered. */
22
+ readonly binary: string | null;
23
+ readonly registered: boolean;
24
+ /**
25
+ * Empty when the harness is not installed, matching what the text rendering
26
+ * says: with no binary there is nothing to register the documents against, and
27
+ * reporting them would describe a setup that does not exist.
28
+ */
29
+ readonly documents: readonly ListedDocument[];
30
+ }
31
+
32
+ export interface McpListing {
33
+ readonly name: string;
34
+ readonly scope: string;
35
+ readonly harnesses: readonly ListedHarness[];
36
+ }
37
+
38
+ export interface McpListFlags {
39
+ readonly name?: string | undefined;
40
+ readonly scope?: string | undefined;
41
+ readonly json?: boolean | undefined;
42
+ }
43
+
6
44
  /**
7
45
  * `lanes link mcp list` — where this endpoint is registered, and whether what
8
46
  * each harness has been told about it is still what we ship.
@@ -11,58 +49,114 @@ import { exists } from './register.ts';
11
49
  * different fixes. A registration without the skill is a working endpoint
12
50
  * nobody reaches for; a skill against no registration is a document describing
13
51
  * tools that are not there.
52
+ *
53
+ * The listing is gathered before anything is printed, so `--json` and the text
54
+ * rendering describe the same snapshot rather than two probes taken a moment
55
+ * apart. `--json` exists because this is the one command another program has a
56
+ * reason to read: it is how a UI decides between "add" and "re-add", and the
57
+ * `stale` state is the only signal that a re-run would do something.
14
58
  */
15
- export async function mcpList(options: { name?: string | undefined; scope?: string | undefined }): Promise<void> {
59
+ export async function mcpList(options: McpListFlags): Promise<void> {
16
60
  const name = options.name ?? 'lanes-link';
17
61
  const scope = options.scope ?? 'user';
18
62
 
19
- heading(`Registered as ${style.bold(name)}`);
63
+ const listing = await listRegistrations(name, scope);
64
+
65
+ await emit(options.json, listing, () => render(listing));
66
+ }
67
+
68
+ /** The whole answer, gathered. No printing, so a caller can have it as data. */
69
+ export async function listRegistrations(name: string, scope: string): Promise<McpListing> {
70
+ const harnesses: ListedHarness[] = [];
20
71
 
21
72
  for (const harness of HARNESSES) {
22
73
  const binary = Bun.which(harness.binary);
23
74
 
24
75
  if (!binary) {
25
- print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
76
+ harnesses.push({
77
+ id: harness.id,
78
+ label: harness.label,
79
+ installed: false,
80
+ binary: null,
81
+ registered: false,
82
+ documents: [],
83
+ });
26
84
  continue;
27
85
  }
28
86
 
29
- print(
30
- ` ${harness.label.padEnd(14)} ${
31
- exists(binary, harness, name)
32
- ? style.green('registered')
33
- : style.dim(`not registered — lanes link mcp add ${harness.id}`)
34
- }`,
35
- );
36
-
37
- for (const line of await documentLines(harness, scope)) print(` ${' '.repeat(14)} ${line}`);
87
+ harnesses.push({
88
+ id: harness.id,
89
+ label: harness.label,
90
+ installed: true,
91
+ binary,
92
+ registered: exists(binary, harness, name),
93
+ documents: await documents(harness, scope),
94
+ });
38
95
  }
96
+
97
+ return { name, scope, harnesses };
39
98
  }
40
99
 
41
- /** One line per document this harness can hold, saying whether it is current. */
42
- async function documentLines(harness: Harness, scope: string): Promise<string[]> {
43
- const lines: string[] = [];
100
+ /** One entry per document this harness can hold, saying whether it is current. */
101
+ async function documents(harness: Harness, scope: string): Promise<ListedDocument[]> {
102
+ const listed: ListedDocument[] = [];
44
103
 
45
104
  for (const plan of plannedAssets(harness, scope)) {
46
105
  try {
47
- const state = await assetState(plan, await readAsset(plan.asset));
48
-
49
- lines.push(
50
- state === 'current'
51
- ? style.dim(`${plan.asset.label}: `) + style.green('up to date')
52
- : style.dim(
53
- state === 'stale'
54
- ? `${plan.asset.label}: out of date — lanes link mcp add ${harness.id}`
55
- : `${plan.asset.label}: not installed — lanes link mcp add ${harness.id}`,
56
- ),
57
- );
106
+ listed.push({
107
+ label: plan.asset.label,
108
+ path: plan.path,
109
+ state: await assetState(plan, await readAsset(plan.asset)),
110
+ });
58
111
  } catch (error) {
59
112
  // A checkout without `instructions/` — a container image, say. Worth one
60
113
  // line rather than a thrown error that hides the registration column.
61
- lines.push(style.dim(`${plan.asset.label}: ${message(error)}`));
114
+ listed.push({
115
+ label: plan.asset.label,
116
+ path: plan.path,
117
+ state: 'unreadable',
118
+ detail: message(error),
119
+ });
120
+ }
121
+ }
122
+
123
+ return listed;
124
+ }
125
+
126
+ function render(listing: McpListing): void {
127
+ heading(`Registered as ${style.bold(listing.name)}`);
128
+
129
+ for (const harness of listing.harnesses) {
130
+ if (!harness.installed) {
131
+ print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
132
+ continue;
133
+ }
134
+
135
+ print(
136
+ ` ${harness.label.padEnd(14)} ${
137
+ harness.registered
138
+ ? style.green('registered')
139
+ : style.dim(`not registered — lanes link mcp add ${harness.id}`)
140
+ }`,
141
+ );
142
+
143
+ for (const document of harness.documents) {
144
+ print(` ${' '.repeat(14)} ${documentLine(harness.id, document)}`);
62
145
  }
63
146
  }
147
+ }
64
148
 
65
- return lines;
149
+ function documentLine(id: string, document: ListedDocument): string {
150
+ switch (document.state) {
151
+ case 'current':
152
+ return style.dim(`${document.label}: `) + style.green('up to date');
153
+ case 'stale':
154
+ return style.dim(`${document.label}: out of date — lanes link mcp add ${id}`);
155
+ case 'missing':
156
+ return style.dim(`${document.label}: not installed — lanes link mcp add ${id}`);
157
+ case 'unreadable':
158
+ return style.dim(`${document.label}: ${document.detail}`);
159
+ }
66
160
  }
67
161
 
68
162
  function message(error: unknown): string {
@@ -29,9 +29,29 @@ export function pluck(value: unknown, path: string): string | null {
29
29
  return typeof current === 'string' && current.length > 0 ? current : null;
30
30
  }
31
31
 
32
+ /** The header an OAuth or bearer provider's probe carries. */
33
+ async function bearerHeaders(probe: IdentityProbe): Promise<RequestInit> {
34
+ const token = await probe.accessToken();
35
+ return { headers: token ? { authorization: `Bearer ${token}` } : {} };
36
+ }
37
+
32
38
  export interface IdentityProbe {
33
39
  /** A valid upstream access token, if the provider authenticates. */
34
40
  readonly accessToken: () => Promise<string | null>;
41
+ /**
42
+ * Put this connection's credential on a request, whatever method it is.
43
+ *
44
+ * Supplied where `accessToken` cannot answer. A stored `api_key`, `header` or
45
+ * `basic` credential is not a bearer token and `bearerToken` throws for all
46
+ * three — under a comment calling the branch unreachable, which it is on an
47
+ * mcp connector and is not on an http one. The throw was caught by the
48
+ * catch-all below, so an `identity: { kind: http }` block on the commonest
49
+ * custom shape there is — a REST API behind an API key — never worked and
50
+ * never said so: the operator was asked to name the account by hand on every
51
+ * reconnect, and a different answer each time is a new row rather than a
52
+ * repair.
53
+ */
54
+ readonly authorize?: (request: Request) => Promise<Request>;
35
55
  /** Call a capability on the upstream MCP server. */
36
56
  readonly callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
37
57
  /** Ask the connector, for a protocol whose identity is not a URL away. */
@@ -52,11 +72,15 @@ export async function resolveAccount(
52
72
  }
53
73
 
54
74
  if (identity.kind === 'http') {
55
- const token = await probe.accessToken();
56
- const request = probe.fetch ?? globalThis.fetch;
57
- const response = await request(identity.url, {
58
- headers: token ? { authorization: `Bearer ${token}` } : {},
59
- });
75
+ const send = probe.fetch ?? globalThis.fetch;
76
+
77
+ // `authorize` where the caller supplied one, because it is the same
78
+ // switch the dispatch path uses and knows every method. The token is the
79
+ // fallback rather than the other way round only because the OAuth
80
+ // providers reached here first; both end at the same header for them.
81
+ const response = probe.authorize
82
+ ? await send(await probe.authorize(new Request(identity.url)))
83
+ : await send(identity.url, await bearerHeaders(probe));
60
84
  if (!response.ok) return null;
61
85
 
62
86
  const body = await response.json();
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { connect } from './commands/connect/index.ts';
2
+ import { connectCustom } from './commands/connect/custom/index.ts';
2
3
  import {
3
4
  attachFile,
4
5
  auditTail,
@@ -29,7 +30,7 @@ import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
29
30
  import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
30
31
  import { dispatchOwner } from './dispatch-owner.ts';
31
32
  import { update } from './commands/update.ts';
32
- import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
33
+ import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
33
34
  import { assertKnownFlags, requireSelection } from './selection.ts';
34
35
  import { PROGRAM, USAGE } from './usage.ts';
35
36
  import { version } from './version.ts';
@@ -75,18 +76,41 @@ export async function run(argv: readonly string[]): Promise<void> {
75
76
 
76
77
  switch (first) {
77
78
  case 'connect':
78
- if (!second) throw new Error(`Usage: ${PROGRAM} connect <provider>`);
79
- return connect(second, {
80
- ...global,
81
- id: text(flags, 'id'),
82
- displayName: text(flags, 'display-name'),
83
- replace: flags['replace'] === true,
84
- nonInteractive: flags['non-interactive'] === true,
85
- acceptBroadScopes: flags['accept-broad-scopes'] === true,
86
- ownClient: flags['own-client'] === true,
87
- auth: text(flags, 'auth'),
88
- json,
89
- });
79
+ // A nested switch rather than an `if`, so `selection.test.ts` sees
80
+ // `connect custom` when it reads this file for `case` labels: it matches
81
+ // on eight-space indentation, and a command invisible to that check is a
82
+ // command that can default quietly.
83
+ switch (second) {
84
+ case 'custom':
85
+ return connectCustom(rest[0], {
86
+ ...global,
87
+ ...customFlags(flags, argv),
88
+ id: text(flags, 'id'),
89
+ displayName: text(flags, 'display-name'),
90
+ replace: flags['replace'] === true,
91
+ nonInteractive: flags['non-interactive'] === true,
92
+ acceptBroadScopes: flags['accept-broad-scopes'] === true,
93
+ json,
94
+ });
95
+
96
+ // Not a `case` label, so it adds no row to `SELECTION`: there is no
97
+ // command here to classify, only a usage error.
98
+ case undefined:
99
+ throw new Error(`Usage: ${PROGRAM} connect <provider>`);
100
+
101
+ default:
102
+ return connect(second, {
103
+ ...global,
104
+ id: text(flags, 'id'),
105
+ displayName: text(flags, 'display-name'),
106
+ replace: flags['replace'] === true,
107
+ nonInteractive: flags['non-interactive'] === true,
108
+ acceptBroadScopes: flags['accept-broad-scopes'] === true,
109
+ ownClient: flags['own-client'] === true,
110
+ auth: text(flags, 'auth'),
111
+ json,
112
+ });
113
+ }
90
114
 
91
115
  case 'setup':
92
116
  if (second !== 'plan' && second !== undefined) {
@@ -297,7 +321,11 @@ export async function run(argv: readonly string[]): Promise<void> {
297
321
  return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
298
322
  case 'list':
299
323
  case undefined:
300
- return mcpList({ name: text(flags, 'name'), scope: text(flags, 'scope') });
324
+ return mcpList({
325
+ name: text(flags, 'name'),
326
+ scope: text(flags, 'scope'),
327
+ json: flags['json'] === true,
328
+ });
301
329
  default:
302
330
  throw new Error(`Unknown: ${PROGRAM} mcp ${second}`);
303
331
  }
package/src/cli/oauth.ts CHANGED
@@ -66,6 +66,27 @@ export interface OAuthFlowOptions {
66
66
  * on the way back. What changes is only the address the vendor is told.
67
67
  */
68
68
  readonly relayRedirect?: string;
69
+ /**
70
+ * A redirect this machine listens on, named to the vendor exactly as written.
71
+ *
72
+ * The third answer to "where does the browser come back to", and the one the
73
+ * comment above did not anticipate. `relayRedirect` exists for a vendor that
74
+ * will take no loopback address at all; this is for a vendor that takes one
75
+ * happily but matches it *exactly*, port included — so the kernel-chosen port
76
+ * below can never match a URL registered in a console months earlier.
77
+ *
78
+ * That is not a rare shape. It is the reason `providers/github/index.ts`
79
+ * gives for using a pasted token rather than OAuth: "an OAuth App matches its
80
+ * callback URL exactly, including the port, and `connect` listens on a port
81
+ * the kernel picks."
82
+ *
83
+ * The whole URL rather than just a port, because the two must be identical
84
+ * strings and only one of them is ours to choose. A console that accepts
85
+ * `localhost` but not `127.0.0.1` — or insists on a trailing path — decides
86
+ * the spelling, and a manifest that could only name a number would have to
87
+ * guess the rest right.
88
+ */
89
+ readonly fixedRedirect?: string;
69
90
  /** What the completion page names as connected. A provider's display name. */
70
91
  readonly connectionLabel?: string;
71
92
  /** How long to wait for the operator to finish in the browser. */
@@ -130,48 +151,80 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
130
151
  rejectCode = reject;
131
152
  });
132
153
 
133
- const server = Bun.serve({
134
- hostname: '127.0.0.1',
135
- port: 0, // let the OS choose; nothing outside this machine names this port
136
- fetch(request) {
137
- const url = new URL(request.url);
138
- if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
139
-
140
- const error = url.searchParams.get('error');
141
- if (error) {
142
- rejectCode(
143
- new OAuthError(
144
- error === 'access_denied'
145
- ? 'Authorization was declined in the browser.'
146
- : `Authorization failed: ${error}`,
147
- ),
148
- );
149
- return failedPage('You can close this tab.');
150
- }
154
+ // A fixed redirect is only fixed if we listen where it says. Parsed rather
155
+ // than configured separately so the two cannot disagree: the port the vendor
156
+ // was told is by construction the port this binds.
157
+ const fixed = options.fixedRedirect ? new URL(options.fixedRedirect) : undefined;
158
+ const fixedPort = fixed ? Number(fixed.port) : undefined;
151
159
 
152
- const returnedState = url.searchParams.get('state');
153
- if (!returnedState || !stateMatches(state, returnedState)) {
154
- // A mismatched state means this callback did not come from the
155
- // request we started. Refuse it rather than redeeming whatever code
156
- // it carries.
157
- rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
158
- return failedPage('Unexpected callback.');
159
- }
160
+ if (fixed && !fixedPort) {
161
+ throw new OAuthError(
162
+ `The redirect "${options.fixedRedirect}" names no port, so there is nothing for this machine to listen on. Register a redirect with an explicit port, such as http://127.0.0.1:8765/callback.`,
163
+ );
164
+ }
160
165
 
161
- const code = url.searchParams.get('code');
162
- if (!code) {
163
- rejectCode(new OAuthError('The callback carried no authorization code.'));
164
- return failedPage('No code returned.');
165
- }
166
+ // Bun.serve throws synchronously when a port is taken. With a kernel-chosen
167
+ // port that cannot happen; with a fixed one it is the ordinary failure — a
168
+ // previous run still holding the socket, or another program on the port the
169
+ // vendor was told about. The raw EADDRINUSE names neither the provider nor
170
+ // the redirect, so it reads as a crash rather than as something to fix.
171
+ const server = ((): ReturnType<typeof Bun.serve> => {
172
+ try {
173
+ return Bun.serve({
174
+ hostname: '127.0.0.1',
175
+ // Zero lets the OS choose, which is right whenever nothing outside this
176
+ // machine names the port. A fixed redirect is exactly the case where
177
+ // something does.
178
+ port: fixedPort ?? 0,
179
+ fetch(request) {
180
+ const url = new URL(request.url);
181
+ if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
166
182
 
167
- resolveCode(code);
168
- return connectedPage(options.connectionLabel);
169
- },
170
- });
183
+ const error = url.searchParams.get('error');
184
+ if (error) {
185
+ rejectCode(
186
+ new OAuthError(
187
+ error === 'access_denied'
188
+ ? 'Authorization was declined in the browser.'
189
+ : `Authorization failed: ${error}`,
190
+ ),
191
+ );
192
+ return failedPage('You can close this tab.');
193
+ }
194
+
195
+ const returnedState = url.searchParams.get('state');
196
+ if (!returnedState || !stateMatches(state, returnedState)) {
197
+ // A mismatched state means this callback did not come from the
198
+ // request we started. Refuse it rather than redeeming whatever code
199
+ // it carries.
200
+ rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
201
+ return failedPage('Unexpected callback.');
202
+ }
203
+
204
+ const code = url.searchParams.get('code');
205
+ if (!code) {
206
+ rejectCode(new OAuthError('The callback carried no authorization code.'));
207
+ return failedPage('No code returned.');
208
+ }
209
+
210
+ resolveCode(code);
211
+ return connectedPage(options.connectionLabel);
212
+ },
213
+ });
214
+ } catch (cause) {
215
+ if (fixedPort) {
216
+ throw new OAuthError(
217
+ `Port ${fixedPort} is already in use, so the callback for this connection cannot be served. The redirect registered with the provider names that port exactly, so it cannot simply be moved — free the port and try again.`,
218
+ );
219
+ }
220
+ throw cause;
221
+ }
222
+ })();
171
223
 
172
224
  // Where the vendor is told to send the browser. The relay bounces it back
173
225
  // here; without one it comes here directly, which is every other provider.
174
- const redirectUri = options.relayRedirect ?? `http://127.0.0.1:${server.port}/callback`;
226
+ const redirectUri =
227
+ options.relayRedirect ?? options.fixedRedirect ?? `http://127.0.0.1:${server.port}/callback`;
175
228
 
176
229
  /**
177
230
  * The CSRF binding, and — behind a relay — the only way home.