@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,189 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { installRoot } from '#profile';
5
+ import { print, style } from '../../output.ts';
6
+ import type { Harness } from './harnesses.ts';
7
+
8
+ /**
9
+ * The documents this repository ships *to* a client, and how they get there.
10
+ *
11
+ * They live under `instructions/` — one root, because they are one thing said
12
+ * three ways. The MCP server says the short version of it to every client that
13
+ * connects (`server/mcp/instructions.ts`); these two are the long version, for
14
+ * the harnesses that can hold a file.
15
+ *
16
+ * Deliberately not read by the server. `.dockerignore` keeps `instructions/`
17
+ * out of the image, so a deployed revision has no copy of either — which is
18
+ * exactly why the endpoint describes itself from code instead.
19
+ */
20
+
21
+ export type AssetKind = 'skill' | 'agent';
22
+
23
+ export interface BundledAsset {
24
+ readonly kind: AssetKind;
25
+ /** Under `instructions/` here. */
26
+ readonly source: string;
27
+ /** Under the harness's directory for that kind, once installed. */
28
+ readonly target: string;
29
+ readonly label: string;
30
+ }
31
+
32
+ export const ASSETS: readonly BundledAsset[] = [
33
+ {
34
+ kind: 'skill',
35
+ source: 'skills/lanes-link/SKILL.md',
36
+ // A directory rather than a flat `lanes-link.md`, so a skill that later
37
+ // grows a reference or a script has somewhere to put it without moving.
38
+ target: 'lanes-link/SKILL.md',
39
+ label: 'skill',
40
+ },
41
+ {
42
+ kind: 'agent',
43
+ source: 'agents/lanes-link-scout.md',
44
+ target: 'lanes-link-scout.md',
45
+ label: 'scout agent',
46
+ },
47
+ ];
48
+
49
+ /** Where an asset lives in this checkout. */
50
+ export function sourcePath(asset: BundledAsset): string {
51
+ return join(installRoot(import.meta.dir), 'instructions', asset.source);
52
+ }
53
+
54
+ export async function readAsset(asset: BundledAsset): Promise<string> {
55
+ const path = sourcePath(asset);
56
+
57
+ if (!existsSync(path)) {
58
+ throw new Error(
59
+ `The bundled ${asset.label} is missing from ${path}. ` +
60
+ `It ships in the repository under instructions/${asset.source}.`,
61
+ );
62
+ }
63
+
64
+ return readFile(path, 'utf8');
65
+ }
66
+
67
+ export interface AssetPlan {
68
+ readonly asset: BundledAsset;
69
+ readonly path: string;
70
+ }
71
+
72
+ /**
73
+ * What this harness would receive, at this scope.
74
+ *
75
+ * A harness with no directory for a kind simply contributes nothing for it —
76
+ * Codex takes the skill and has nowhere to put a subagent, and that is a fact
77
+ * about Codex rather than a failure to report.
78
+ */
79
+ export function plannedAssets(harness: Harness, scope: string): AssetPlan[] {
80
+ const plans: AssetPlan[] = [];
81
+
82
+ for (const asset of ASSETS) {
83
+ const directory = asset.kind === 'skill' ? harness.skills?.(scope) : harness.agents?.(scope);
84
+ if (directory) plans.push({ asset, path: join(directory, asset.target) });
85
+ }
86
+
87
+ return plans;
88
+ }
89
+
90
+ export type AssetState = 'installed' | 'updated' | 'unchanged';
91
+
92
+ /**
93
+ * Write one asset, reporting what that actually changed.
94
+ *
95
+ * Compared before writing rather than written unconditionally, for two reasons
96
+ * that are both about the second run. It makes re-running honest — "unchanged"
97
+ * is different information from "updated", and someone re-running after a token
98
+ * rotation wants to know the document did not move under them. And it keeps the
99
+ * file's mtime still, which matters because a harness that watches its skills
100
+ * directory would otherwise reload on every `mcp add`.
101
+ */
102
+ export async function installAsset(plan: AssetPlan, body: string): Promise<AssetState> {
103
+ const existing = existsSync(plan.path) ? await readFile(plan.path, 'utf8') : null;
104
+ if (existing === body) return 'unchanged';
105
+
106
+ await mkdir(dirname(plan.path), { recursive: true });
107
+ await writeFile(plan.path, body);
108
+
109
+ return existing === null ? 'installed' : 'updated';
110
+ }
111
+
112
+ /** Whether what is on disk is what we ship, without writing anything. */
113
+ export async function assetState(
114
+ plan: AssetPlan,
115
+ body: string,
116
+ ): Promise<'current' | 'stale' | 'missing'> {
117
+ if (!existsSync(plan.path)) return 'missing';
118
+ return (await readFile(plan.path, 'utf8')) === body ? 'current' : 'stale';
119
+ }
120
+
121
+ /**
122
+ * Install every asset this harness takes, and say what happened to each.
123
+ *
124
+ * Errors are reported rather than thrown: a read-only home directory should not
125
+ * abandon a registration that already succeeded, and the operator can still
126
+ * install by hand from `lanes link mcp skill`.
127
+ */
128
+ export async function installFor(
129
+ harness: Harness,
130
+ scope: string,
131
+ options: { dryRun?: boolean | undefined },
132
+ ): Promise<void> {
133
+ for (const plan of plannedAssets(harness, scope)) {
134
+ if (options.dryRun) {
135
+ print(style.dim(` would write ${plan.asset.label} to ${plan.path}`));
136
+ continue;
137
+ }
138
+
139
+ try {
140
+ const state = await installAsset(plan, await readAsset(plan.asset));
141
+ print(
142
+ style.dim(
143
+ state === 'unchanged'
144
+ ? ` ${plan.asset.label} already current at ${plan.path}`
145
+ : ` ${state} ${plan.asset.label} at ${plan.path}`,
146
+ ),
147
+ );
148
+ } catch (error) {
149
+ print(style.dim(` could not install the ${plan.asset.label}: ${message(error)}`));
150
+ }
151
+ }
152
+ }
153
+
154
+ function message(error: unknown): string {
155
+ return error instanceof Error ? error.message : String(error);
156
+ }
157
+
158
+ /**
159
+ * `lanes link mcp skill` — the bundled skill, as a path or as the document.
160
+ *
161
+ * Still here after `mcp add` learned to install it, because it is the answer for
162
+ * every client that has no skills directory to install into: Claude Desktop,
163
+ * Cowork, and anything else that reads a URL and nothing else. `--print` sends
164
+ * the document to stdout so it can be piped somewhere this CLI has never heard
165
+ * of.
166
+ */
167
+ export async function skillDocument(options: { print?: boolean | undefined }): Promise<void> {
168
+ const asset = ASSETS.find((candidate) => candidate.kind === 'skill')!;
169
+ const body = await readAsset(asset);
170
+
171
+ if (options.print) {
172
+ print(body.trimEnd());
173
+ return;
174
+ }
175
+
176
+ const directory = dirname(sourcePath(asset));
177
+ print(directory);
178
+ print('');
179
+ print(style.dim('Installed for you by "lanes link mcp add". By hand, for Claude Code:'));
180
+ print(` cp -r ${directory} ~/.claude/skills/`);
181
+ print('');
182
+ print(
183
+ style.dim(
184
+ 'For a client with nowhere to put a file, "lanes link mcp skill --print" writes the\n' +
185
+ 'document to stdout — though such a client is already told the short version of it\n' +
186
+ 'by the endpoint itself, when it connects.',
187
+ ),
188
+ );
189
+ }
@@ -0,0 +1,143 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The agent harnesses this CLI knows how to set up, and the two different
6
+ * things "setting up" means.
7
+ *
8
+ * **Registration is delegated.** `add`, `get`, and `remove` are argument lists
9
+ * for the harness's *own* command, because where Claude Code and Codex keep
10
+ * their servers, and what belongs in those files, is not ours to define. If
11
+ * either CLI changes shape, this breaks loudly rather than writing a stale
12
+ * format.
13
+ *
14
+ * **Assets are written.** `skills` and `agents` name directories we put a
15
+ * document in, because there is no `claude skill add` to delegate to. The line
16
+ * between the two is the whole of ADR-016: delegate where the harness owns a
17
+ * command, write the file where it does not. A skill is content in a documented
18
+ * location, not a config format we would be guessing at, and the directory
19
+ * written is named after this project.
20
+ */
21
+
22
+ export interface AddInput {
23
+ readonly name: string;
24
+ readonly url: string;
25
+ readonly token: string;
26
+ readonly tokenEnv: string;
27
+ readonly scope: string;
28
+ }
29
+
30
+ export interface Harness {
31
+ readonly id: string;
32
+ readonly binary: string;
33
+ readonly label: string;
34
+ /** Whether `--scope` means anything here. Codex config is global. */
35
+ readonly scoped: boolean;
36
+ /**
37
+ * Whether the token is handed to the harness at all.
38
+ *
39
+ * Codex stores the *name* of an environment variable and reads it when it
40
+ * launches, so the secret never reaches its config file and a rotation is
41
+ * picked up without re-registering. Claude Code stores the header value.
42
+ */
43
+ readonly storesToken: boolean;
44
+ /**
45
+ * This harness's configuration directory, honouring its own override.
46
+ *
47
+ * Read at call time rather than captured, because a test sets the variable
48
+ * around the call and a value resolved at import would be the real home.
49
+ */
50
+ home(): string;
51
+ add(input: AddInput): string[];
52
+ get(name: string): string[];
53
+ remove(name: string): string[];
54
+ /** Where user- or project-scope skills go, if this harness reads any. */
55
+ skills?(scope: string): string;
56
+ /** Where subagent definitions go. Codex has no equivalent. */
57
+ agents?(scope: string): string;
58
+ /** Anything the operator still has to do themselves. */
59
+ afterAdd?(input: AddInput): string[];
60
+ }
61
+
62
+ /**
63
+ * A scoped directory under a harness's home.
64
+ *
65
+ * `user` is the home itself; anything else is the project-local `.<harness>`
66
+ * directory beside the code, which is where both Claude Code scopes that are
67
+ * not `user` look. Resolved against the current directory deliberately — a
68
+ * project-scope install belongs to the checkout someone is standing in.
69
+ */
70
+ function scoped(home: string, dotDirectory: string, scope: string, kind: string): string {
71
+ return scope === 'user' ? join(home, kind) : join(process.cwd(), dotDirectory, kind);
72
+ }
73
+
74
+ const CLAUDE_HOME = (): string => process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude');
75
+ const CODEX_HOME = (): string => process.env['CODEX_HOME'] ?? join(homedir(), '.codex');
76
+
77
+ export const HARNESSES: readonly Harness[] = [
78
+ {
79
+ id: 'claude',
80
+ binary: 'claude',
81
+ label: 'Claude Code',
82
+ scoped: true,
83
+ storesToken: true,
84
+ home: CLAUDE_HOME,
85
+ add: ({ name, url, token, scope }) => [
86
+ 'mcp',
87
+ 'add',
88
+ '--transport',
89
+ 'http',
90
+ name,
91
+ url,
92
+ '--header',
93
+ `Authorization: Bearer ${token}`,
94
+ '--scope',
95
+ scope,
96
+ ],
97
+ // No scope on get: it looks across all of them, which is what we want —
98
+ // a name registered at user scope still collides with a local-scope add.
99
+ get: (name) => ['mcp', 'get', name],
100
+ // No scope on remove either: "removes from whichever scope it exists in",
101
+ // so a user-scope registration cannot survive a local-scope --force.
102
+ remove: (name) => ['mcp', 'remove', name],
103
+ skills: (scope) => scoped(CLAUDE_HOME(), '.claude', scope, 'skills'),
104
+ agents: (scope) => scoped(CLAUDE_HOME(), '.claude', scope, 'agents'),
105
+ },
106
+ {
107
+ id: 'codex',
108
+ binary: 'codex',
109
+ label: 'Codex',
110
+ scoped: false,
111
+ storesToken: false,
112
+ home: CODEX_HOME,
113
+ add: ({ name, url, tokenEnv }) => [
114
+ 'mcp',
115
+ 'add',
116
+ name,
117
+ '--url',
118
+ url,
119
+ '--bearer-token-env-var',
120
+ tokenEnv,
121
+ ],
122
+ get: (name) => ['mcp', 'get', name],
123
+ remove: (name) => ['mcp', 'remove', name],
124
+ // Same layout and the same frontmatter Claude Code reads, so one document
125
+ // installs to both unchanged. Codex has no subagent directory, so it gets
126
+ // the skill and not the scout — and `mcp add` says which it did.
127
+ skills: () => join(CODEX_HOME(), 'skills'),
128
+ afterAdd: ({ tokenEnv }) => [
129
+ `Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
130
+ '',
131
+ ` export ${tokenEnv}="$(lanes link token show --raw)"`,
132
+ '',
133
+ 'Add that to your shell profile. This is the better half of the bargain: the token never',
134
+ 'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
135
+ 'with no re-registration.',
136
+ ],
137
+ },
138
+ ];
139
+
140
+ /** The command shapes, exposed so a test can pin them against each CLI's help. */
141
+ export function harnessCommands(id: string): Harness | undefined {
142
+ return HARNESSES.find((harness) => harness.id === id);
143
+ }
@@ -0,0 +1,70 @@
1
+ import { heading, print, style } from '../../output.ts';
2
+ import { assetState, plannedAssets, readAsset } from './assets.ts';
3
+ import { HARNESSES, type Harness } from './harnesses.ts';
4
+ import { exists } from './register.ts';
5
+
6
+ /**
7
+ * `lanes link mcp list` — where this endpoint is registered, and whether what
8
+ * each harness has been told about it is still what we ship.
9
+ *
10
+ * Two columns because there are two ways to be half set up, and they have
11
+ * different fixes. A registration without the skill is a working endpoint
12
+ * nobody reaches for; a skill against no registration is a document describing
13
+ * tools that are not there.
14
+ */
15
+ export async function mcpList(options: { name?: string | undefined; scope?: string | undefined }): Promise<void> {
16
+ const name = options.name ?? 'lanes-link';
17
+ const scope = options.scope ?? 'user';
18
+
19
+ heading(`Registered as ${style.bold(name)}`);
20
+
21
+ for (const harness of HARNESSES) {
22
+ const binary = Bun.which(harness.binary);
23
+
24
+ if (!binary) {
25
+ print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
26
+ continue;
27
+ }
28
+
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}`);
38
+ }
39
+ }
40
+
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[] = [];
44
+
45
+ for (const plan of plannedAssets(harness, scope)) {
46
+ 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
+ );
58
+ } catch (error) {
59
+ // A checkout without `instructions/` — a container image, say. Worth one
60
+ // line rather than a thrown error that hides the registration column.
61
+ lines.push(style.dim(`${plan.asset.label}: ${message(error)}`));
62
+ }
63
+ }
64
+
65
+ return lines;
66
+ }
67
+
68
+ function message(error: unknown): string {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
@@ -0,0 +1,199 @@
1
+ import { endpointUrl } from '../../endpoint-url.ts';
2
+ import { fail, ok, print, style, warn } from '../../output.ts';
3
+ import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
4
+ import { installFor } from './assets.ts';
5
+ import { HARNESSES, type AddInput, type Harness } from './harnesses.ts';
6
+
7
+ /**
8
+ * `lanes link mcp add [harness]` — register this endpoint with an agent, and
9
+ * give that agent the document that says what it is for.
10
+ *
11
+ * The registration half does **not** write to any agent's config file. It runs
12
+ * the harness's own supported command, which keeps the registration format the
13
+ * harness's business. If either CLI changes, this breaks loudly rather than
14
+ * writing a stale format.
15
+ *
16
+ * The install half does write a file, because no harness has a command for it
17
+ * (ADR-016). Those are different risks: a registration we spelled wrong is a
18
+ * config file we corrupted, and a skill we spelled wrong is a Markdown document
19
+ * in a directory named after us.
20
+ *
21
+ * The manual command from `lanes link outputs` remains the canonical path. This
22
+ * exists because typing it correctly turns out to be error-prone: an em-dash
23
+ * from a paste, or a `lanes` not on PATH substituting to an empty token,
24
+ * both produce a 401 that reads as a bad credential.
25
+ */
26
+
27
+ export interface McpAddOptions extends GlobalFlags {
28
+ readonly name?: string | undefined;
29
+ readonly scope?: string | undefined;
30
+ readonly tokenEnv?: string | undefined;
31
+ readonly dryRun?: boolean | undefined;
32
+ readonly force?: boolean | undefined;
33
+ /** Register only; leave the skill and the agent alone. */
34
+ readonly noSkill?: boolean | undefined;
35
+ }
36
+
37
+ export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
38
+ // No harness named: every one that is actually installed. Registering with
39
+ // whatever is present is what someone means by "add my mcp", and naming one
40
+ // stays available for the case where it is not.
41
+ const chosen = target
42
+ ? HARNESSES.filter((harness) => harness.id === target)
43
+ : HARNESSES.filter((harness) => Bun.which(harness.binary));
44
+
45
+ if (target && chosen.length === 0) {
46
+ throw new Error(
47
+ `Unknown harness "${target}". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\n` +
48
+ ' For anything else, "lanes link outputs" prints the URL and token to register by hand.',
49
+ );
50
+ }
51
+
52
+ if (chosen.length === 0) {
53
+ throw new Error(
54
+ `None of ${HARNESSES.map((h) => h.binary).join(', ')} is on your PATH, so there is nothing to run.\n` +
55
+ ' Run "lanes link outputs" and register by hand instead.',
56
+ );
57
+ }
58
+
59
+ const name = options.name ?? 'lanes-link';
60
+ // User scope, not project scope.
61
+ //
62
+ // This registers a gateway to someone's *accounts* — their mail, calendar,
63
+ // files. Claude Code's `local` scope binds a server to one directory, so a
64
+ // project-scoped registration means your inbox is reachable from
65
+ // ~/dev/thing and not from ~/dev/other, which nobody expects and nothing
66
+ // about the endpoint suggests. `local` is right for a repository's own
67
+ // tooling; it is wrong for this.
68
+ const scope = options.scope ?? 'user';
69
+ const tokenEnv = options.tokenEnv ?? 'LANES_LINK_TOKEN';
70
+
71
+ const runtime = await openRuntime(options);
72
+
73
+ try {
74
+ const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
75
+
76
+ // The target's own address, not the local one. This built
77
+ // `http://<host>:<port>/mcp` unconditionally, so `mcp add --target cloud`
78
+ // registered loopback with the agent: a registration that reports success,
79
+ // names the right server, and points at a port with nothing behind it.
80
+ const url = await endpointUrl(runtime.config, runtime.target);
81
+ const input: AddInput = { name, url, token, tokenEnv, scope };
82
+
83
+ // Registering an endpoint that is down is legitimate — the harness stores
84
+ // the address and connects on demand — but it is usually a mistake worth
85
+ // mentioning, since the first symptom is a failed tool call much later.
86
+ if (!(await reachable(url))) {
87
+ const start = url.startsWith('http://127.') || url.startsWith('http://localhost')
88
+ ? 'run "lanes link start" before using it'
89
+ : 'the deployed service is not answering — check "lanes link outputs"';
90
+ print(warn(`nothing is answering on ${url} — ${start}`));
91
+ }
92
+
93
+ for (const harness of chosen) await register(harness, input, options);
94
+ } finally {
95
+ await runtime.close();
96
+ }
97
+ }
98
+
99
+ async function register(
100
+ harness: Harness,
101
+ input: AddInput,
102
+ options: McpAddOptions,
103
+ ): Promise<void> {
104
+ const binary = Bun.which(harness.binary);
105
+ if (!binary) {
106
+ print(fail(`${harness.label} ("${harness.binary}") is not on your PATH`));
107
+ return;
108
+ }
109
+
110
+ const args = harness.add(input);
111
+
112
+ if (options.dryRun) {
113
+ // The token is redacted here and only here: a dry run exists precisely so
114
+ // someone can read the command before it carries a live credential.
115
+ print(` ${harness.binary} ${redactToken(args).map(quote).join(' ')}`);
116
+ if (!options.noSkill) await installFor(harness, input.scope, { dryRun: true });
117
+ return;
118
+ }
119
+
120
+ if (exists(binary, harness, input.name)) {
121
+ if (!options.force) {
122
+ print(warn(`${harness.label}: "${input.name}" is already registered`));
123
+ print(style.dim(' --force replaces it, e.g. after "lanes link token rotate"'));
124
+ // The documents are still refreshed. Someone re-running this after an
125
+ // upgrade wants the current skill, and refusing to update it because the
126
+ // registration was already right is the opposite of what they asked for.
127
+ if (!options.noSkill) await installFor(harness, input.scope, {});
128
+ return;
129
+ }
130
+ // Remove then add: both CLIs refuse a duplicate name rather than updating,
131
+ // and a stale token is the usual reason anyone is here.
132
+ Bun.spawnSync([binary, ...harness.remove(input.name)], { stdout: 'ignore', stderr: 'ignore' });
133
+ }
134
+
135
+ const result = Bun.spawnSync([binary, ...args], { stdout: 'pipe', stderr: 'pipe' });
136
+
137
+ if (!result.success) {
138
+ print(fail(`${harness.label}: ${harness.binary} exited ${result.exitCode}`));
139
+ const stderr = new TextDecoder().decode(result.stderr).trim();
140
+ if (stderr) print(style.dim(` ${stderr}`));
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+
145
+ print(
146
+ ok(
147
+ `registered ${style.bold(input.name)} with ${harness.label}` +
148
+ (harness.scoped ? ` (${input.scope} scope)` : ''),
149
+ ),
150
+ );
151
+ print(` ${input.url}`);
152
+
153
+ if (!options.noSkill) await installFor(harness, input.scope, {});
154
+
155
+ if (harness.storesToken) {
156
+ print(
157
+ style.dim(
158
+ ' The token was stored as a value, not a command, so "lanes link token rotate"\n' +
159
+ ' means running this again with --force.',
160
+ ),
161
+ );
162
+ }
163
+
164
+ const after = harness.afterAdd?.(input);
165
+ if (after) {
166
+ print('');
167
+ for (const line of after) print(line ? ` ${line}` : '');
168
+ }
169
+ }
170
+
171
+ /** Whether the harness already knows this name, so we replace rather than fail. */
172
+ export function exists(binary: string, harness: Harness, name: string): boolean {
173
+ return Bun.spawnSync([binary, ...harness.get(name)], {
174
+ stdout: 'ignore',
175
+ stderr: 'ignore',
176
+ }).success;
177
+ }
178
+
179
+ async function reachable(url: string): Promise<boolean> {
180
+ try {
181
+ const health = new URL(url);
182
+ health.pathname = '/health';
183
+ return (await fetch(health, { signal: AbortSignal.timeout(700) })).ok;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /** Quote an argument a shell would otherwise split, so a dry run is pasteable. */
190
+ function quote(argument: string): string {
191
+ return /[\s"'$`\\]/.test(argument) ? `"${argument.replace(/(["$`\\])/g, '\\$1')}"` : argument;
192
+ }
193
+
194
+ /** Replace the bearer value, so a dry run can be pasted into a bug report. */
195
+ function redactToken(args: readonly string[]): string[] {
196
+ return args.map((argument) =>
197
+ argument.startsWith('Authorization: Bearer ') ? 'Authorization: Bearer <token>' : argument,
198
+ );
199
+ }
@@ -0,0 +1,57 @@
1
+ import { printErr } from '../../output.ts';
2
+ import type { GlobalFlags } from '../../runtime.ts';
3
+ import { startStdioEndpoint } from '#server/endpoint.ts';
4
+
5
+ /**
6
+ * `lanes link mcp stdio` — serve this workspace on stdin and stdout.
7
+ *
8
+ * Not a command anyone types. It is what a client's config file names, so the
9
+ * client spawns it: Claude Desktop validates each `mcpServers` entry against
10
+ * `{ command, args?, env?, extensionId? }` and has no `url` field, so an HTTP
11
+ * endpoint is not something it can be pointed at.
12
+ *
13
+ * Such a client also has nowhere to install a skill, which is the case the
14
+ * server's own `instructions` exist for — it arrives over this pipe like any
15
+ * other, in the `initialize` response.
16
+ *
17
+ * Nothing may be written to stdout here — it is the wire. Reconcile output and
18
+ * errors go to stderr, where a client's log captures them.
19
+ */
20
+ export async function mcpStdio(
21
+ flags: GlobalFlags & { only?: boolean | undefined },
22
+ ): Promise<void> {
23
+ const endpoint = await startStdioEndpoint({
24
+ flags,
25
+ ...(flags.only ? { only: true } : {}),
26
+ reporter: {
27
+ reconciled: ({ profile, plan, ofMany }) =>
28
+ printErr(`${ofMany ? `${profile}\n` : ''}${plan}`),
29
+ // Unreachable: this path never mints a token, because it never needs one.
30
+ tokenMinted: () => {},
31
+ },
32
+ log: {
33
+ debug() {},
34
+ info() {},
35
+ warn: (message, fields) => printErr(`warn ${message} ${JSON.stringify(fields ?? {})}`),
36
+ error: (message, fields) => printErr(`error ${message} ${JSON.stringify(fields ?? {})}`),
37
+ },
38
+ });
39
+
40
+ const stop = async (code: number): Promise<never> => {
41
+ await endpoint.stop();
42
+ process.exit(code);
43
+ };
44
+
45
+ process.on('SIGINT', () => void stop(0));
46
+ process.on('SIGTERM', () => void stop(0));
47
+
48
+ // The client owns the lifetime: it spawned this process, and closing the pipe
49
+ // is how it says it is done. Without this the process outlives the client that
50
+ // started it, holding a database handle open for nobody.
51
+ await new Promise<void>((resolve) => {
52
+ process.stdin.once('end', resolve);
53
+ process.stdin.once('close', resolve);
54
+ });
55
+
56
+ await endpoint.stop();
57
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Setting an agent up to use this endpoint.
3
+ *
4
+ * A barrel beside the directory, matching `operate.ts` and `owner.ts` and for
5
+ * the same reason: `main.ts` binds `./mcp.ts`, and Bun does not resolve that to
6
+ * `mcp/index.ts`.
7
+ *
8
+ * Five files, because "set up an agent" turned out to be two jobs with
9
+ * different rules (ADR-016):
10
+ *
11
+ * harnesses which agents we know, and what each will accept
12
+ * register running the harness's own registration command
13
+ * assets writing the documents no harness has a command for
14
+ * list what is set up, and what has drifted
15
+ * stdio serving the client that spawns us instead of connecting
16
+ */
17
+
18
+ export { skillDocument } from './mcp/assets.ts';
19
+ export { harnessCommands } from './mcp/harnesses.ts';
20
+ export { mcpList } from './mcp/list.ts';
21
+ export { mcpAdd, type McpAddOptions } from './mcp/register.ts';
22
+ export { mcpStdio } from './mcp/stdio.ts';