@lanes-sh/link 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,323 @@
1
+ import { z } from 'zod';
2
+ import { defineLocalProvider, keepKeys, type ProviderDefinition, type ProviderManifest } from '#connectivity';
3
+ import { planAll, planFor, type ProviderPlan } from './plan.ts';
4
+
5
+ /**
6
+ * `setup` — what is connected, and what connecting something else would take.
7
+ *
8
+ * **Read-only by construction, and that is the whole design.** ADR-007 keeps
9
+ * connection creation, credential writing, OAuth, and configuration mutation off
10
+ * the MCP surface, because each authorises *future* agent behaviour and the
11
+ * decision has to originate outside the agent. Describing what setup requires
12
+ * authorises nothing: it is the same category as `tools/list`, which already
13
+ * tells a caller what exists. ADR-019 records the argument.
14
+ *
15
+ * So there are two capabilities, both reads, and no write bundle. The absence is
16
+ * a decision rather than an omission — see `provider.test.ts`, which asserts the
17
+ * capability list holds nothing else.
18
+ *
19
+ * Everything it reports is either shipped code (which providers exist, what
20
+ * their setup blocks say) or already visible to the same caller through
21
+ * `tools/list`, the injected `profile` and `connection` enums, and the
22
+ * unauthenticated `/health`. The one genuinely new fact is the account *label*
23
+ * on a connection — `you@example.com` — and it is deliberate: a caller holding a
24
+ * grant on `gmail.main` can already read that mailbox, which discloses the
25
+ * address on the first message. Withholding it here while serving the mailbox
26
+ * would be theatre, and the label is the entire point.
27
+ *
28
+ * What it must never report is whether a credential is *present*. That is the
29
+ * requires/satisfied split: this surface says what setup needs, and only
30
+ * `lanes link` says what is already there. `missingRequirements` is CLI-only for
31
+ * exactly this reason.
32
+ *
33
+ * The names are load-bearing. `control-plane.test.ts` runs seven unanchored
34
+ * patterns over every registered capability id, so `setup.connection_steps`
35
+ * trips the `connect` pattern (`.connect` matches inside `.connection_`) and
36
+ * `setup.credentials_needed` trips the credential one. `overview` and `provider`
37
+ * are clean. Renaming either is not a cosmetic change.
38
+ */
39
+
40
+ export interface SetupProviderOptions {
41
+ /**
42
+ * Which profile this instance serves.
43
+ *
44
+ * Stamped at construction because a handler cannot learn it: `makeHandler`
45
+ * strips `profile` off the arguments before dispatch, and `ProviderContext`
46
+ * does not carry it. Every emitted command needs it, so it has to arrive
47
+ * here — one registry is built per profile, so each instance gets its own.
48
+ */
49
+ readonly profile: string;
50
+ /** Sibling profile names on this endpoint. Names only; already at `/health`. */
51
+ readonly profiles?: readonly string[];
52
+ /**
53
+ * `oauth_apps` entries this profile declares.
54
+ *
55
+ * Configuration, not a credential: it names which vendors this profile holds
56
+ * a client of its own for, never whether the client is stored. Reporting a
57
+ * provider as needing nothing when the profile has in fact registered one
58
+ * would send the owner to a command that then asks for two values.
59
+ */
60
+ readonly ownClients?: readonly string[];
61
+ /** Every provider this build ships. Shipped code, identical for every caller. */
62
+ readonly catalogue?: readonly ProviderManifest[];
63
+ /**
64
+ * Accounts this principal can actually reach, in this profile.
65
+ *
66
+ * A function, not a snapshot, so it is evaluated per call. Computed by the
67
+ * caller because filtering it is a policy decision and `#providers` may not
68
+ * import `#policy` — `open.ts` runs it through the same `allowedConnections`
69
+ * the dispatcher enforces with and `mergeCapabilities` builds the connection
70
+ * enum from. Computing it separately here is how discovery and enforcement
71
+ * drift, and a leak in discovery is still a leak.
72
+ */
73
+ readonly reachable?: () => ReadonlyArray<{ key: string; provider: string; account: string }>;
74
+ }
75
+
76
+ export function createSetupProvider(options: SetupProviderOptions): ProviderDefinition {
77
+ const catalogue = options.catalogue ?? [];
78
+ const reachable = options.reachable ?? (() => []);
79
+
80
+ const context = () => ({
81
+ profile: options.profile,
82
+ connections: reachable().map((connection) => connection.key),
83
+ ...(options.ownClients ? { ownClients: options.ownClients } : {}),
84
+ });
85
+
86
+ return defineLocalProvider({
87
+ id: 'setup',
88
+ name: 'Setup',
89
+ version: '1.0.0',
90
+ description:
91
+ 'What this endpoint is connected to, and what connecting something else would involve. ' +
92
+ 'Read-only: nothing here writes configuration, stores a credential, runs a sign-in, or ' +
93
+ 'changes what is permitted — those are control-plane operations and stay in the CLI.',
94
+
95
+ configSchema: z.object({}),
96
+ connectionSchema: z.object({}),
97
+
98
+ bundles: [
99
+ {
100
+ name: 'read',
101
+ description: 'Describe what is set up. There is no write bundle, by design.',
102
+ oauth_scopes: [],
103
+ capabilities: ['overview', 'provider'],
104
+ default: true,
105
+ },
106
+ ],
107
+
108
+ capabilities: [
109
+ {
110
+ kind: 'tool',
111
+ name: 'overview',
112
+ title: 'What is set up',
113
+ description:
114
+ 'List the accounts reachable in this profile and the providers that could be connected. ' +
115
+ 'Call this before answering any question about what you can reach, or before suggesting ' +
116
+ 'that something be set up.',
117
+ inputSchema: z.object({}),
118
+ // No arguments to redact.
119
+ async handler(_input, handlerContext) {
120
+ const connections = reachable();
121
+ const plans = planAll(catalogue, context());
122
+
123
+ return {
124
+ content: [
125
+ {
126
+ type: 'text',
127
+ text: renderOverview(options, connections, plans, handlerContext.connection.key),
128
+ },
129
+ ],
130
+ };
131
+ },
132
+ },
133
+
134
+ {
135
+ kind: 'tool',
136
+ name: 'provider',
137
+ title: 'What connecting one provider takes',
138
+ description:
139
+ 'The console steps, the values needed, and the exact command that connects it. ' +
140
+ 'Use this to tell the owner what to run — do not compose the command yourself.',
141
+ inputSchema: z.object({
142
+ id: z.string().min(1).describe('Provider id, as listed by setup_overview — e.g. "notion"'),
143
+ connection: z
144
+ .string()
145
+ .optional()
146
+ .describe('Name for the account, when the provider stores a credential per account'),
147
+ }),
148
+ // A provider id is a name this project ships, not the owner's data, so
149
+ // it is worth recording verbatim: an audit line that cannot say which
150
+ // provider was asked about answers very little. `connection` is a label
151
+ // the caller chose and is type-marked with everything else.
152
+ redact: keepKeys('id'),
153
+ async handler({ id, connection }, _handlerContext) {
154
+ const manifest = catalogue.find((candidate) => candidate.id === id);
155
+
156
+ if (!manifest) {
157
+ return {
158
+ content: [
159
+ {
160
+ type: 'text',
161
+ text:
162
+ `No provider "${id}". Call setup_overview for the ids this endpoint knows.`,
163
+ },
164
+ ],
165
+ isError: true,
166
+ };
167
+ }
168
+
169
+ return {
170
+ content: [{ type: 'text', text: renderProvider(planFor(manifest, context(), connection)) }],
171
+ };
172
+ },
173
+ },
174
+ ],
175
+ });
176
+ }
177
+
178
+ function renderOverview(
179
+ options: SetupProviderOptions,
180
+ connections: ReadonlyArray<{ key: string; account: string }>,
181
+ plans: readonly ProviderPlan[],
182
+ self: string,
183
+ ): string {
184
+ const lines: string[] = [`Profile "${options.profile}".`, ''];
185
+
186
+ if (connections.length === 0) {
187
+ lines.push('No accounts are connected and reachable here yet.');
188
+ } else {
189
+ lines.push('Connected and reachable:');
190
+ for (const connection of connections) {
191
+ lines.push(` ${connection.key} — ${connection.account}`);
192
+ }
193
+ }
194
+
195
+ // Anything already connected is filtered out by `connected`, and anything
196
+ // configured but not granted never reached `reachable()` — so a denied
197
+ // provider appears here as merely available, indistinguishable from one that
198
+ // was never connected. That is ADR-007's "probing must not be an oracle".
199
+ //
200
+ // Both lists key off `plan.connected`, which is the policy-filtered set, and
201
+ // never off the raw config. Keying the second one off what is *configured*
202
+ // would say "gmail is connected" about a connection policy hides, which is
203
+ // the oracle this is careful not to be.
204
+ const available = plans.filter((plan) => plan.connected.length === 0);
205
+
206
+ if (available.length > 0) {
207
+ lines.push('', 'Could be connected:');
208
+ for (const plan of available) {
209
+ lines.push(` ${plan.id}${plan.browser ? ' (needs a browser sign-in)' : ''} — ${plan.description}`);
210
+ }
211
+ lines.push('', 'For what one of them takes, call setup_provider with its id.');
212
+ }
213
+
214
+ // A provider used to vanish from this surface the moment it had one
215
+ // connection, so "connect another Gmail account" — the question this exists
216
+ // to answer — had no answer in the overview at all, and an agent reading it
217
+ // would conclude none was possible and improvise. Saying so costs one line.
218
+ const more = plans.filter((plan) => plan.connected.length > 0 && plan.multiAccount);
219
+
220
+ if (more.length > 0) {
221
+ lines.push(
222
+ '',
223
+ `Already connected, and able to hold a further account: ${more.map((plan) => plan.id).join(', ')}.`,
224
+ 'Connecting a second account is the same command again — call setup_provider with the id for it.',
225
+ );
226
+ }
227
+
228
+ const siblings = (options.profiles ?? []).filter((name) => name !== options.profile);
229
+ if (siblings.length > 0) {
230
+ lines.push(
231
+ '',
232
+ `This endpoint also serves: ${siblings.join(', ')}. Pass that profile to see what it reaches.`,
233
+ );
234
+ }
235
+
236
+ lines.push(
237
+ '',
238
+ 'Setting anything up is done by the owner, in a terminal — this endpoint cannot do it. ' +
239
+ 'A connection they make is served here within moments of them making it.',
240
+ `(reported for connection ${self})`,
241
+ );
242
+
243
+ return lines.join('\n');
244
+ }
245
+
246
+ function renderProvider(plan: ProviderPlan): string {
247
+ const lines: string[] = [`${plan.name} — ${plan.description}`];
248
+
249
+ if (plan.summary) lines.push('', plan.summary);
250
+ if (plan.docsUrl) lines.push(plan.docsUrl);
251
+
252
+ if (plan.connected.length > 0) {
253
+ lines.push('', `Already connected here: ${plan.connected.join(', ')}.`);
254
+
255
+ // Without this the reader has a list of existing accounts and a command,
256
+ // and no way to know the command is the one that adds another rather than
257
+ // the one that already ran.
258
+ if (plan.multiAccount) {
259
+ lines.push(
260
+ 'The command below adds another account rather than replacing those — ' +
261
+ 'each account is its own connection.',
262
+ );
263
+ }
264
+ }
265
+
266
+ // A console walkthrough is withheld from this surface when it does not apply.
267
+ // A model handed nine steps it has no reason to relay will relay them, and
268
+ // the owner ends up registering a client they did not need.
269
+ if (plan.steps.length > 0 && !plan.brokered) {
270
+ lines.push('', 'The owner does this first, in the vendor’s own console:');
271
+ plan.steps.forEach((step, index) => lines.push(` ${index + 1}. ${step}`));
272
+ }
273
+
274
+ if (plan.brokered) {
275
+ lines.push(
276
+ '',
277
+ `There is nothing to register: the OAuth client is operated by ${plan.clientOperator}, ` +
278
+ 'and its secret never reaches this machine. The command below is the whole of it.',
279
+ );
280
+ }
281
+
282
+ // Labels, never the credential references they resolve to. The command below
283
+ // *asks* for each of these, so the owner never needs to know where a value is
284
+ // filed — and a ref names a key in the credential store, which is a detail of
285
+ // ours rather than anything they can act on. The `secrets set` spelling exists
286
+ // for `lanes link setup plan`, where the reader has a shell and is scripting.
287
+ if (plan.requires.length > 0) {
288
+ lines.push('', 'It will ask them for:');
289
+ for (const requirement of plan.requires) lines.push(` ${requirement.label}`);
290
+ }
291
+
292
+ if (plan.needsId) {
293
+ lines.push(
294
+ '',
295
+ 'This provider stores a credential per account, so the command needs a name for the ' +
296
+ 'account — replace <name> with whatever they want to call it.',
297
+ );
298
+ }
299
+
300
+ lines.push('', 'The command:', ` ${plan.command}`);
301
+
302
+ if (plan.brokered && plan.ownClientCommand) {
303
+ lines.push(
304
+ '',
305
+ 'If the owner would rather use an OAuth client they register themselves — some ' +
306
+ 'organisations require it — the same command takes --own-client and then asks for ' +
307
+ `the client id and secret:`,
308
+ ` ${plan.ownClientCommand}`,
309
+ );
310
+ }
311
+
312
+ if (plan.browser) {
313
+ lines.push(
314
+ '',
315
+ 'That opens a browser for consent, so it has to be run by whoever owns the account. ' +
316
+ 'Give them the line above rather than trying to run it.',
317
+ );
318
+ }
319
+
320
+ lines.push('', 'Once it is done this becomes reachable here, with nothing further to run.');
321
+
322
+ return lines.join('\n');
323
+ }
@@ -0,0 +1,119 @@
1
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
2
+ import { ConfigError } from '#profile';
3
+
4
+ /**
5
+ * YAML frontmatter between `---` fences, and the Markdown body after it.
6
+ *
7
+ * Two owner-layer artifacts are stored this way — a skill and a memory entry —
8
+ * and they parse identically on purpose. One file per thing, holding both its
9
+ * metadata and its text, is what makes either of them hand-editable: an entry
10
+ * whose title lived in a database row beside a body in a blob could not be
11
+ * opened in an editor, and could go out of sync with itself.
12
+ *
13
+ * The format is the one every other tool that reads a skill file already uses,
14
+ * which is the reason it was not invented here.
15
+ */
16
+
17
+ export interface Frontmatter {
18
+ readonly frontmatter: Record<string, unknown>;
19
+ readonly body: string;
20
+ }
21
+
22
+ /**
23
+ * Split a document, refusing one whose frontmatter is absent or malformed.
24
+ *
25
+ * Each failure is reported as itself rather than as one generic message, and
26
+ * `source` names the file in all of them: these are read from a directory a
27
+ * person edits by hand, where "could not parse frontmatter" without a filename
28
+ * or a reason is not a diagnosis.
29
+ */
30
+ export function splitFrontmatter(text: string, source: string): Frontmatter {
31
+ const normalised = stripBom(text);
32
+
33
+ if (!normalised.startsWith('---')) {
34
+ throw new ConfigError(
35
+ `${source}: expected YAML frontmatter between "---" fences at the start of the file.`,
36
+ );
37
+ }
38
+
39
+ const fences = splitAtFences(normalised);
40
+ if (!fences) {
41
+ throw new ConfigError(`${source}: the frontmatter block is never closed with "---".`);
42
+ }
43
+
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = parseYaml(fences.head);
47
+ } catch (error) {
48
+ throw new ConfigError(`${source}: could not parse frontmatter — ${(error as Error).message}`);
49
+ }
50
+
51
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
52
+ throw new ConfigError(`${source}: the frontmatter must be a YAML mapping.`);
53
+ }
54
+
55
+ return { frontmatter: parsed as Record<string, unknown>, body: fences.body };
56
+ }
57
+
58
+ /**
59
+ * Split a document that may not have frontmatter, treating the whole of it as
60
+ * the body when it does not.
61
+ *
62
+ * A memory entry uses this rather than `splitFrontmatter`: entries are files in
63
+ * a directory the owner is invited to edit, and a plain Markdown file dropped
64
+ * in there should read as an untitled entry rather than becoming an error that
65
+ * hides the rest of the directory behind it.
66
+ */
67
+ export function splitOptionalFrontmatter(text: string): Frontmatter {
68
+ const normalised = stripBom(text);
69
+ if (!normalised.startsWith('---')) return { frontmatter: {}, body: normalised };
70
+
71
+ const fences = splitAtFences(normalised);
72
+ if (!fences) return { frontmatter: {}, body: normalised };
73
+
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = parseYaml(fences.head);
77
+ } catch {
78
+ return { frontmatter: {}, body: normalised };
79
+ }
80
+
81
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
+ return { frontmatter: {}, body: normalised };
83
+ }
84
+
85
+ return { frontmatter: parsed as Record<string, unknown>, body: fences.body };
86
+ }
87
+
88
+ /** Serialise frontmatter and a body back into one document. */
89
+ export function withFrontmatter(data: Record<string, unknown>, body: string): string {
90
+ const head = stringifyYaml(data).trimEnd();
91
+ return `---\n${head}\n---\n\n${body.replace(/^\n+/, '')}`;
92
+ }
93
+
94
+ /** A frontmatter value that should be a list of strings, tolerating a bare one. */
95
+ export function stringList(raw: unknown): string[] {
96
+ if (typeof raw === 'string') return [raw];
97
+ if (!Array.isArray(raw)) return [];
98
+ return raw.filter((entry): entry is string => typeof entry === 'string');
99
+ }
100
+
101
+ function splitAtFences(normalised: string): { head: string; body: string } | null {
102
+ const end = normalised.indexOf('\n---', 3);
103
+ if (end === -1) return null;
104
+
105
+ const rest = normalised.slice(normalised.indexOf('\n', end + 1) + 1);
106
+
107
+ return {
108
+ head: normalised.slice(normalised.indexOf('\n') + 1, end),
109
+ // One blank line after the closing fence is the conventional separator, not
110
+ // content. Exactly one is dropped, so a body that deliberately opens with
111
+ // blank lines keeps the rest of them — and so `withFrontmatter` round-trips
112
+ // a body back to itself.
113
+ body: rest.startsWith('\n') ? rest.slice(1) : rest,
114
+ };
115
+ }
116
+
117
+ function stripBom(text: string): string {
118
+ return text.replace(/^/, '');
119
+ }