@lanes-sh/link 0.7.2 → 0.8.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 (156) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -1,4 +1,4 @@
1
- import type { IdentityEntry, ProfileSelection, Resolution } from '#profile';
1
+ import { CONNECTIONS_FILE, type IdentityEntry, type ProfileSelection, type Resolution } from '#profile';
2
2
  import { ConfigDocument } from '../config-edit.ts';
3
3
  import { ensureIdentityConnection, repairLines, repaired } from '../config-repair.ts';
4
4
  import { announce, announceProfile, emit, ok, print, style, table, warn } from '../output.ts';
@@ -51,14 +51,20 @@ function covers(rules: ReadonlyArray<{ capability: string }>): boolean {
51
51
  * misleading thing this command could print.
52
52
  */
53
53
  function readable(config: {
54
- connections: ReadonlyArray<{ provider: string }>;
55
- policy: { allow: ReadonlyArray<{ capability: string }>; deny: ReadonlyArray<{ capability: string }> };
54
+ grants: ReadonlyArray<{
55
+ connection: string;
56
+ allow: ReadonlyArray<{ capability: string }>;
57
+ deny: ReadonlyArray<{ capability: string }>;
58
+ }>;
56
59
  }): boolean {
57
- return (
58
- config.connections.some((connection) => connection.provider === 'identity') &&
59
- covers(config.policy.allow) &&
60
- !covers(config.policy.deny)
61
- );
60
+ // One row now rather than a connection and a rule that had to agree
61
+ // (ADR-058). The old shape could say "declared but not granted" and
62
+ // "granted but not declared", and both served nothing while reading like
63
+ // configuration that worked; neither is expressible here.
64
+ const grant = config.grants.find((row) => row.connection.startsWith('identity.'));
65
+ if (grant === undefined) return false;
66
+
67
+ return covers(grant.allow) && !covers(grant.deny);
62
68
  }
63
69
 
64
70
  /**
@@ -96,7 +102,13 @@ export async function addIdentity(
96
102
  const entry: IdentityEntry = { kind, value, ...(options.note ? { note: options.note } : {}) };
97
103
  document.addTo(['identity'], entry, { inline: true });
98
104
 
99
- const repair = ensureIdentityConnection(document);
105
+ // The identity *connection* is the workspace's, like every other (ADR-057),
106
+ // so provisioning the surface on first use touches two files. Both saves, or
107
+ // neither: a grant naming a connection that does not exist is refused at load,
108
+ // so writing the profile alone would leave one that no longer opens.
109
+ const connections = await ConfigDocument.openKey(resolution.workspaceRoot, CONNECTIONS_FILE);
110
+ const repair = ensureIdentityConnection(connections, document);
111
+ await connections.save();
100
112
  await document.save();
101
113
 
102
114
  return {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ soleGrantFor,
2
3
  ConfigError,
3
4
  KNOWLEDGE_LAYOUT,
4
5
  knowledgeTargetSchema,
@@ -23,7 +24,6 @@ import {
23
24
  removeLocal,
24
25
  summarise,
25
26
  } from './migrate.ts';
26
-
27
27
  /**
28
28
  * `lanes link knowledge` — where this profile's memory and skills are kept.
29
29
  *
@@ -65,78 +65,6 @@ export interface KnowledgeFlags extends GlobalFlags {
65
65
  readonly fetch?: FetchLike | undefined;
66
66
  }
67
67
 
68
- export async function knowledgeShow(flags: KnowledgeFlags): Promise<void> {
69
- const runtime = await openRuntime(flags, { fetch: flags.fetch });
70
- try {
71
- if (!flags.json) announce(runtime.resolution);
72
-
73
- const profile = runtime.config.instance.profile;
74
- const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
75
- const skills = (await runtime.skills.list()).length;
76
- const memory = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.memory}/`)).length;
77
- const entities = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.entities}/`)).length;
78
- const where = runtime.knowledge?.describe;
79
-
80
- if (flags.json) {
81
- print(
82
- JSON.stringify(
83
- { target: runtime.target, where: where ?? 'local', memory, skills, entities },
84
- null,
85
- 2,
86
- ),
87
- );
88
- return;
89
- }
90
-
91
- heading('Knowledge');
92
- table([
93
- // The memory *directory*, not the blob root it sits in. `layout.blobs`
94
- // is `data/<profile>`, which is where every provider's namespace lives —
95
- // printing it here would name a directory that is mostly not memory.
96
- [
97
- ' memory',
98
- where ? `${where}/${KNOWLEDGE_LAYOUT.memory}` : `${layout.blobs(profile)}/${KNOWLEDGE_LAYOUT.memory}`,
99
- style.dim(`${memory} file${memory === 1 ? '' : 's'}`),
100
- ],
101
- [
102
- ' skills',
103
- where ? `${where}/${KNOWLEDGE_LAYOUT.skills}` : layout.skills(profile),
104
- style.dim(`${skills} file${skills === 1 ? '' : 's'}`),
105
- ],
106
- // The count includes the derived `_index.json`, deliberately: it is a
107
- // file in that directory and it is committed with the rest, so a number
108
- // that quietly excluded it would not match what a person sees there.
109
- [
110
- ' entities',
111
- where
112
- ? `${where}/${KNOWLEDGE_LAYOUT.entities}`
113
- : `${layout.blobs(profile)}/${KNOWLEDGE_LAYOUT.entities}`,
114
- style.dim(`${entities} file${entities === 1 ? '' : 's'}`),
115
- ],
116
- ]);
117
-
118
- print('');
119
- print(
120
- style.dim(
121
- ` The vault, the credential store, runtime state and the audit log stay in target "${runtime.target}".`,
122
- ),
123
- );
124
- // Complete commands, not shapes. Every one of these is pasted, and with
125
- // nothing left to fall back on (ADR-037) a line missing either flag is a
126
- // line that refuses — `emitted.test.ts` states the rule for the templates
127
- // reachable without a runtime, and this is the same rule where there is one.
128
- print(
129
- style.dim(
130
- where
131
- ? ` Bring them back with: lanes link knowledge use local --migrate${selection}`
132
- : ` Keep them in a repository with: lanes link knowledge use github --repo <owner/name>${selection}`,
133
- ),
134
- );
135
- } finally {
136
- await runtime.close();
137
- }
138
- }
139
-
140
68
  export async function knowledgeUse(where: string | undefined, flags: KnowledgeFlags): Promise<void> {
141
69
  if (where === 'local') return useLocal(flags);
142
70
  if (where === 'github') return useGithub(flags);
@@ -172,7 +100,7 @@ async function useGithub(flags: KnowledgeFlags): Promise<void> {
172
100
  const runtime = await openRuntime(flags, { fetch: flags.fetch });
173
101
  try {
174
102
  announce(runtime.resolution);
175
- const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
103
+ const selection = ` --profile ${runtime.resolution.profile} --workspace ${runtime.target}`;
176
104
 
177
105
  if (runtime.knowledge) {
178
106
  print(style.dim(` This profile already reads ${runtime.knowledge.describe}.`));
@@ -206,7 +134,16 @@ async function useGithub(flags: KnowledgeFlags): Promise<void> {
206
134
  [' token', viewer, style.dim('can write')],
207
135
  ]);
208
136
 
209
- const movable = await localContents(runtime.storage, runtime.skills, knowledge);
137
+ // Which instances this profile grants, so `memory/` and `entities/` reach
138
+ // only its own and not every profile's (ADR-059).
139
+ const instances = grantedInstances(runtime.config);
140
+
141
+ const movable = await localContents(
142
+ runtime.storage,
143
+ runtime.skills ?? null,
144
+ knowledge,
145
+ instances,
146
+ );
210
147
  const moving = await decideMigration(movable.length > 0, flags);
211
148
 
212
149
  if (moving) {
@@ -225,7 +162,7 @@ async function useGithub(flags: KnowledgeFlags): Promise<void> {
225
162
  if (flags.keep) {
226
163
  print(style.dim(' --keep: the local copies are still there, and are no longer read.'));
227
164
  } else {
228
- await removeLocal(runtime.storage, runtime.skills, movable);
165
+ await removeLocal(runtime.storage, runtime.skills ?? null, movable, instances);
229
166
  print(ok('removed the local copies'));
230
167
  }
231
168
  } else if (movable.length > 0) {
@@ -280,7 +217,13 @@ async function useLocal(flags: KnowledgeFlags): Promise<void> {
280
217
  flags.migrate ??
281
218
  (await agreedTo(flags, `Copy everything in ${knowledge.repo} back onto this target's storage?`))
282
219
  ) {
283
- const moved = await moveOut(repository, knowledge, local.storage, local.skills);
220
+ const moved = await moveOut(
221
+ repository,
222
+ knowledge,
223
+ local.storage,
224
+ local.skills,
225
+ grantedInstances(runtime.config),
226
+ );
284
227
  print(
285
228
  ok(
286
229
  `wrote back ${moved.memory} memory entr${moved.memory === 1 ? 'y' : 'ies'}, ` +
@@ -314,7 +257,7 @@ async function useLocal(flags: KnowledgeFlags): Promise<void> {
314
257
  */
315
258
  async function openLocalStores(
316
259
  runtime: Runtime,
317
- ): Promise<{ storage: BlobStore; skills: BlobStore }> {
260
+ ): Promise<{ storage: BlobStore; skills: BlobStore | null }> {
318
261
  const { openStorage } = await import('#deployments/target.ts');
319
262
  const declared = runtime.declared;
320
263
 
@@ -327,7 +270,13 @@ async function openLocalStores(
327
270
  },
328
271
  runtime.credentials,
329
272
  );
330
- return { storage: factory(), skills: factory(layout.skills(runtime.config.instance.profile)) };
273
+ // The *granted* connection, not the profile name. `layout.skills` changed
274
+ // meaning without changing arity at ADR-059, so the compiler was silent.
275
+ const skillsConnection = soleGrantFor(runtime.config, 'skills');
276
+ return {
277
+ storage: factory(),
278
+ skills: skillsConnection === undefined ? null : factory(layout.skills(skillsConnection)),
279
+ };
331
280
  }
332
281
 
333
282
  /**
@@ -386,3 +335,21 @@ async function agreedTo(flags: KnowledgeFlags, question: string): Promise<boolea
386
335
  if (!yes) print(style.dim(' cancelled'));
387
336
  return yes;
388
337
  }
338
+
339
+ /**
340
+ * The memory and entities instances one profile grants.
341
+ *
342
+ * `main` where a profile grants none, which is what a profile that predates the
343
+ * owner layer looks like and is also where a fresh one puts them. The point is
344
+ * that this is never the *surface* — `memory/` alone matches every profile's
345
+ * notes now that the blob root is the workspace's.
346
+ */
347
+ function grantedInstances(config: Parameters<typeof soleGrantFor>[0]): {
348
+ memory: string;
349
+ entities: string;
350
+ } {
351
+ return {
352
+ memory: soleGrantFor(config, 'memory') ?? 'main',
353
+ entities: soleGrantFor(config, 'entities') ?? 'main',
354
+ };
355
+ }
@@ -43,16 +43,71 @@ export interface Movable {
43
43
  * it once means `localContents`, `removeLocal` and `moveOut` each became a loop
44
44
  * over this rather than a branch on a literal.
45
45
  */
46
- type LocalArea = { readonly store: BlobStore; readonly prefix: string };
46
+ /**
47
+ * One area's local home: where to look, and what of that key is the repository's.
48
+ *
49
+ * Two prefixes rather than one, and the difference is the whole fix. `scope` is
50
+ * what is listed — `memory/<instance>/`, so a profile sees only the instance it
51
+ * grants. `prefix` is what is stripped to get the repository path, which stays
52
+ * `memory/<instance>/<file>` because that is the layout ADR-041 documents and
53
+ * what makes two profiles' notes distinguishable in one repository.
54
+ *
55
+ * Collapsing them listed every profile's memory and then wrote it flat.
56
+ */
57
+ type LocalArea = {
58
+ readonly store: BlobStore;
59
+ readonly scope: string;
60
+ readonly prefix: string;
61
+ };
47
62
 
48
- function localAreas(storage: BlobStore, skills: BlobStore): Record<KnowledgeArea, LocalArea> {
63
+ /** The connection ids this profile's memory and entities live under. */
64
+ export interface Instances {
65
+ readonly memory: string;
66
+ readonly entities: string;
67
+ }
68
+
69
+ function localAreas(
70
+ storage: BlobStore,
71
+ skills: BlobStore | null,
72
+ /** Which instance of each surface this profile grants (ADR-059). */
73
+ instances: Instances,
74
+ ): Record<KnowledgeArea, LocalArea> {
49
75
  return {
50
- memory: { store: storage, prefix: `${KNOWLEDGE_LAYOUT.memory}/` },
51
- skills: { store: skills, prefix: '' },
52
- entities: { store: storage, prefix: `${KNOWLEDGE_LAYOUT.entities}/` },
76
+ // Scoped to the instance this profile grants, not to the surface.
77
+ //
78
+ // `layout.blobs()` is the whole workspace since contract 3, so a prefix of
79
+ // `memory/` matched every profile's memory — and `knowledge use github
80
+ // --migrate --profile personal` therefore committed `work`'s notes to
81
+ // personal's repository and then deleted them locally, leaving `work`
82
+ // reading an empty store with no knowledge block of its own. `skills` was
83
+ // already connection-scoped, which is why it alone was correct.
84
+ memory: {
85
+ store: storage,
86
+ scope: `${KNOWLEDGE_LAYOUT.memory}/${instances.memory}/`,
87
+ prefix: `${KNOWLEDGE_LAYOUT.memory}/`,
88
+ },
89
+ // Null becomes an empty area rather than a refusal: a profile granting no
90
+ // skills connection has none to move, and `knowledge use` should still move
91
+ // its memory and entities rather than failing on the one area that is empty
92
+ // by construction (ADR-059).
93
+ skills: { store: skills ?? EMPTY_AREA, scope: '', prefix: '' },
94
+ entities: {
95
+ store: storage,
96
+ scope: `${KNOWLEDGE_LAYOUT.entities}/${instances.entities}/`,
97
+ prefix: `${KNOWLEDGE_LAYOUT.entities}/`,
98
+ },
53
99
  };
54
100
  }
55
101
 
102
+ /** Nothing to list, nothing to delete. See `localAreas`. */
103
+ const EMPTY_AREA: BlobStore = {
104
+ get: async () => null,
105
+ put: async () => {},
106
+ has: async () => false,
107
+ delete: async () => {},
108
+ list: async () => [],
109
+ };
110
+
56
111
  const AREAS = ['memory', 'skills', 'entities'] as const;
57
112
 
58
113
  /**
@@ -66,16 +121,18 @@ const AREAS = ['memory', 'skills', 'entities'] as const;
66
121
  */
67
122
  export async function localContents(
68
123
  storage: BlobStore,
69
- skills: BlobStore,
124
+ /** Null when the profile grants no skills connection (ADR-059). */
125
+ skills: BlobStore | null,
70
126
  knowledge: KnowledgeConfig,
127
+ instances: Instances,
71
128
  ): Promise<Movable[]> {
72
- const areas = localAreas(storage, skills);
129
+ const areas = localAreas(storage, skills, instances);
73
130
  const found: Movable[] = [];
74
131
 
75
132
  for (const area of AREAS) {
76
- const { store, prefix } = areas[area];
133
+ const { store, scope, prefix } = areas[area];
77
134
 
78
- for (const entry of await store.list(prefix)) {
135
+ for (const entry of await store.list(scope)) {
79
136
  const data = await store.get(entry.key);
80
137
  if (data === null) continue; // Listed then deleted; not worth failing over.
81
138
 
@@ -128,10 +185,12 @@ export async function moveIn(
128
185
  /** Remove what has been verified elsewhere. Never called before `moveIn`. */
129
186
  export async function removeLocal(
130
187
  storage: BlobStore,
131
- skills: BlobStore,
188
+ /** Null when the profile grants no skills connection (ADR-059). */
189
+ skills: BlobStore | null,
132
190
  movable: readonly Movable[],
191
+ instances: Instances,
133
192
  ): Promise<void> {
134
- const areas = localAreas(storage, skills);
193
+ const areas = localAreas(storage, skills, instances);
135
194
 
136
195
  for (const item of movable) {
137
196
  const { store, prefix } = areas[item.area];
@@ -152,10 +211,12 @@ export async function moveOut(
152
211
  repository: GithubRepository,
153
212
  knowledge: KnowledgeConfig,
154
213
  storage: BlobStore,
155
- skills: BlobStore,
214
+ /** Null when the profile grants no skills connection (ADR-059). */
215
+ skills: BlobStore | null,
216
+ instances: Instances,
156
217
  ): Promise<Record<KnowledgeArea, number>> {
157
218
  const { entries } = await repository.entries();
158
- const areas = localAreas(storage, skills);
219
+ const areas = localAreas(storage, skills, instances);
159
220
  const counts: Record<KnowledgeArea, number> = { memory: 0, skills: 0, entities: 0 };
160
221
 
161
222
  for (const entry of entries.values()) {
@@ -0,0 +1,92 @@
1
+ import { KNOWLEDGE_LAYOUT, layout, soleGrantFor } from '#profile';
2
+ import { announce, emit, heading, print, style, table } from '../../output.ts';
3
+ import { openRuntime } from '../../runtime.ts';
4
+ import type { KnowledgeFlags } from './index.ts';
5
+
6
+ /**
7
+ * `lanes link knowledge show` — where memory, skills and entities actually are.
8
+ *
9
+ * Split from `knowledge use` so `index.ts` stays inside the size budget, on the
10
+ * seam the two commands already have: this one opens stores and counts, and
11
+ * never writes. That difference is why `show` can answer for a repository `use`
12
+ * would refuse to touch.
13
+ */
14
+
15
+ export async function knowledgeShow(flags: KnowledgeFlags): Promise<void> {
16
+ const runtime = await openRuntime(flags, { fetch: flags.fetch });
17
+ try {
18
+ if (!flags.json) announce(runtime.resolution);
19
+
20
+ const profile = runtime.config.instance.profile;
21
+ const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
22
+ const skills = runtime.skills ? (await runtime.skills.list()).length : 0;
23
+ const memory = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.memory}/`)).length;
24
+ const entities = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.entities}/`)).length;
25
+ const where = runtime.knowledge?.describe;
26
+ // Null when no skills connection is granted (ADR-059). The row still prints,
27
+ // saying so: a missing row reads as "there are none", not "not granted".
28
+ const skillsConnection = soleGrantFor(runtime.config, 'skills');
29
+
30
+ if (flags.json) {
31
+ print(
32
+ JSON.stringify(
33
+ { target: runtime.target, where: where ?? 'local', memory, skills, entities },
34
+ null,
35
+ 2,
36
+ ),
37
+ );
38
+ return;
39
+ }
40
+
41
+ heading('Knowledge');
42
+ table([
43
+ // The memory *directory*, not the blob root it sits in. `layout.blobs`
44
+ // is `data/`, which is where every provider's namespace lives —
45
+ // printing it here would name a directory that is mostly not memory.
46
+ [
47
+ ' memory',
48
+ where ? `${where}/${KNOWLEDGE_LAYOUT.memory}` : `${layout.blobs()}/${KNOWLEDGE_LAYOUT.memory}`,
49
+ style.dim(`${memory} file${memory === 1 ? '' : 's'}`),
50
+ ],
51
+ [
52
+ ' skills',
53
+ where
54
+ ? `${where}/${KNOWLEDGE_LAYOUT.skills}`
55
+ : skillsConnection
56
+ ? layout.skills(skillsConnection)
57
+ : style.dim('not granted'),
58
+ style.dim(`${skills} file${skills === 1 ? '' : 's'}`),
59
+ ],
60
+ // The count includes the derived `_index.json`, deliberately: it is a
61
+ // file in that directory and it is committed with the rest, so a number
62
+ // that quietly excluded it would not match what a person sees there.
63
+ [
64
+ ' entities',
65
+ where
66
+ ? `${where}/${KNOWLEDGE_LAYOUT.entities}`
67
+ : `${layout.blobs()}/${KNOWLEDGE_LAYOUT.entities}`,
68
+ style.dim(`${entities} file${entities === 1 ? '' : 's'}`),
69
+ ],
70
+ ]);
71
+
72
+ print('');
73
+ print(
74
+ style.dim(
75
+ ` The vault, the credential store, runtime state and the audit log stay in workspace "${runtime.target}".`,
76
+ ),
77
+ );
78
+ // Complete commands, not shapes. Every one of these is pasted, and with
79
+ // nothing left to fall back on (ADR-037) a line missing either flag is a
80
+ // line that refuses — `emitted.test.ts` states the rule for the templates
81
+ // reachable without a runtime, and this is the same rule where there is one.
82
+ print(
83
+ style.dim(
84
+ where
85
+ ? ` Bring them back with: lanes link knowledge use local --migrate${selection}`
86
+ : ` Keep them in a repository with: lanes link knowledge use github --repo <owner/name>${selection}`,
87
+ ),
88
+ );
89
+ } finally {
90
+ await runtime.close();
91
+ }
92
+ }
@@ -7,4 +7,5 @@
7
7
  * the grammar that puts them in order.
8
8
  */
9
9
 
10
- export { knowledgeShow, knowledgeUse, type KnowledgeFlags } from './knowledge/index.ts';
10
+ export { knowledgeUse, type KnowledgeFlags } from './knowledge/index.ts';
11
+ export { knowledgeShow } from './knowledge/show.ts';
@@ -17,12 +17,31 @@ import { join } from 'node:path';
17
17
  * command, write the file where it does not. A skill is content in a documented
18
18
  * location, not a config format we would be guessing at, and the directory
19
19
  * written is named after this project.
20
+ *
21
+ * **No registration carries a token any more** (ADR-062). Every endpoint runs
22
+ * the authorization flow, discovery is served ahead of the auth gate on
23
+ * loopback as well as deployed, and a client pointed at a bare URL finds
24
+ * `/.well-known/oauth-protected-resource`, signs its owner in at lanes.sh, and
25
+ * comes back with a token of its own. What that removes is worth stating: the
26
+ * registration no longer contains a credential, so a config file synced to a
27
+ * dotfiles repository is no longer a leak, and a rotation does not invalidate
28
+ * every harness at once.
29
+ *
30
+ * The exception is `tokenEnv`, which survives for the one caller that has no
31
+ * browser — see `token show`, which is now documented as a CI command.
20
32
  */
21
33
 
22
34
  export interface AddInput {
23
35
  readonly name: string;
24
36
  readonly url: string;
25
- readonly token: string;
37
+ /**
38
+ * The endpoint's static token, for a harness that cannot run a browser.
39
+ *
40
+ * Nothing passes this in the ordinary path. It is here because
41
+ * `--headless` exists for CI, where there is no browser to complete an
42
+ * authorization in and a pasted credential is the only thing that works.
43
+ */
44
+ readonly token?: string | undefined;
26
45
  readonly tokenEnv: string;
27
46
  readonly scope: string;
28
47
  /**
@@ -44,11 +63,12 @@ export interface Harness {
44
63
  /** Whether `--scope` means anything here. Codex config is global. */
45
64
  readonly scoped: boolean;
46
65
  /**
47
- * Whether the token is handed to the harness at all.
66
+ * Whether a credential reaches the harness's config at all.
48
67
  *
49
- * Codex stores the *name* of an environment variable and reads it when it
50
- * launches, so the secret never reaches its config file and a rotation is
51
- * picked up without re-registering. Claude Code stores the header value.
68
+ * Both are `false` in the ordinary path now the client authorises itself.
69
+ * This still distinguishes what a `--headless` registration would write:
70
+ * Claude Code would hold the header value, and Codex stores only the *name*
71
+ * of an environment variable it reads at launch.
52
72
  */
53
73
  readonly storesToken: boolean;
54
74
  /**
@@ -99,8 +119,10 @@ export const HARNESSES: readonly Harness[] = [
99
119
  'http',
100
120
  name,
101
121
  url,
102
- '--header',
103
- `Authorization: Bearer ${token}`,
122
+ // Only when there is no browser to sign in with. Registering the bare URL
123
+ // is the ordinary path: Claude Code discovers the protected-resource
124
+ // document and runs the authorization itself.
125
+ ...(token ? ['--header', `Authorization: Bearer ${token}`] : []),
104
126
  '--scope',
105
127
  scope,
106
128
  ],
@@ -142,7 +164,7 @@ export const HARNESSES: readonly Harness[] = [
142
164
  // anywhere else: an unresolvable substitution yields the empty string, the
143
165
  // header becomes "Bearer ", and the only symptom is a 401 that reads as a
144
166
  // bad token rather than a command that refused.
145
- ` export ${tokenEnv}="$(lanes link token show --raw --profile ${profile} --target ${target})"`,
167
+ ` export ${tokenEnv}="$(lanes link token show --raw --profile ${profile} --workspace ${target})"`,
146
168
  '',
147
169
  'Add that to your shell profile. This is the better half of the bargain: the token never',
148
170
  'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
@@ -0,0 +1,86 @@
1
+ import { print } from '../../output.ts';
2
+
3
+ /**
4
+ * `lanes link mcp install-instructions` — the block a client should be told once.
5
+ *
6
+ * A registration points a client at this endpoint. It does not tell the *model*
7
+ * anything, and the model is what decides whether to look here at all. The
8
+ * common failure is not a client that cannot reach the endpoint; it is one that
9
+ * can and answers from nothing anyway, because reaching for a tool is a habit
10
+ * and no habit is installed by adding a server to a config file.
11
+ *
12
+ * So this prints a short block for a person to paste into whatever their client
13
+ * treats as durable instruction — a project file, a custom instruction, a
14
+ * memory. It says where things are and what to read first, and nothing else:
15
+ * the full account lives at `lanes://instructions` and is served by the
16
+ * endpoint, so it can be corrected without anybody pasting anything again.
17
+ *
18
+ * **Printed rather than written.** Every client keeps this somewhere different
19
+ * and several keep it somewhere a person curates by hand. Writing into one of
20
+ * those is the class of thing ADR-016 puts on the other side of the line from
21
+ * `mcp add`: delegate where the harness owns a command, write the file only
22
+ * where it does not, and where neither is true, hand over the text.
23
+ */
24
+
25
+ /** What each client calls the place this belongs, so the hint is actionable. */
26
+ const WHERE: Record<string, string> = {
27
+ claude: 'CLAUDE.md in your project, or ~/.claude/CLAUDE.md for every project',
28
+ chatgpt: "Settings → Personalization → Custom instructions, under 'anything else'",
29
+ codex: 'AGENTS.md in your project, or ~/.codex/AGENTS.md',
30
+ cursor: '.cursor/rules/ in your project',
31
+ };
32
+
33
+ export interface InstallInstructionsFlags {
34
+ readonly client?: string | undefined;
35
+ }
36
+
37
+ export function installInstructions(flags: InstallInstructionsFlags = {}): void {
38
+ const client = flags.client?.toLowerCase();
39
+
40
+ if (client !== undefined && !(client in WHERE)) {
41
+ throw new Error(
42
+ `Unknown client "${client}". Known: ${Object.keys(WHERE).join(', ')}.\n` +
43
+ ' Omit --client for the block on its own.',
44
+ );
45
+ }
46
+
47
+ if (client !== undefined) {
48
+ // To stderr, so the block on stdout stays pasteable. Somebody piping this
49
+ // into a file wants the text and not the advice about where to put it.
50
+ process.stderr.write(`Paste this into ${WHERE[client]}:\n\n`);
51
+ }
52
+
53
+ print(BLOCK);
54
+ }
55
+
56
+ /**
57
+ * The block itself.
58
+ *
59
+ * Deliberately short and deliberately not a copy of the endpoint's own
60
+ * instructions. It exists to establish one habit — look here before answering
61
+ * from nothing — and to name the document that carries the rest. A longer block
62
+ * pasted into a project file is one that goes stale in a place nobody will
63
+ * think to update.
64
+ *
65
+ * No vendor names and no account details, for the same reason nothing under
66
+ * `server/` may carry one: what the owner connected is theirs.
67
+ */
68
+ const BLOCK = `## Lanes Link
69
+
70
+ Lanes Link is the route to my accounts, my memory, my notes, my saved procedures
71
+ and my contacts. It is an MCP endpoint, and it is already connected.
72
+
73
+ - Before saying you do not know something about me or my work, search memory.
74
+ - Before using anyone's address or handle, look them up in entities. It returns
75
+ every match and never picks one; if there is more than one, ask me.
76
+ - A thing for me to do is a task, not a memory. Tasks have a status.
77
+ - If I have a saved procedure for something, tell me it exists and let me run it
78
+ rather than improvising your own version.
79
+ - Before telling me something cannot be reached or that an account is missing,
80
+ ask the setup surface. It reports what exists and gives the exact command for
81
+ what does not. Run nothing yourself: every change here is mine to make.
82
+ - Read \`lanes://instructions\` before your first call for the full account of
83
+ how routing, profiles and refusals work.
84
+
85
+ Every tool takes a profile. A profile is how I keep things apart, so when it is
86
+ ambiguous which one I mean, ask rather than picking the first.`;
@@ -32,6 +32,17 @@ export interface McpAddOptions extends GlobalFlags {
32
32
  readonly force?: boolean | undefined;
33
33
  /** Register only; leave the skill and the agent alone. */
34
34
  readonly noSkill?: boolean | undefined;
35
+ /**
36
+ * Write the endpoint token into the registration, for a machine with no browser.
37
+ *
38
+ * The ordinary path registers a bare URL and the client authorises itself
39
+ * against lanes.sh (ADR-062), which is both safer and less to get wrong. This
40
+ * is the CI escape hatch, and it is a flag rather than a fallback because the
41
+ * difference has to be a decision somebody made: a registration that quietly
42
+ * embedded a credential when a browser was unavailable is one nobody would
43
+ * notice had done so.
44
+ */
45
+ readonly headless?: boolean | undefined;
35
46
  }
36
47
 
37
48
  export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
@@ -71,17 +82,20 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
71
82
  const runtime = await openRuntime(options);
72
83
 
73
84
  try {
85
+ // Minted either way. The endpoint needs one to serve at all, and `outputs`
86
+ // prints it; what `--headless` decides is whether it is written into
87
+ // somebody's agent config.
74
88
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
75
89
 
76
90
  // The target's own address, not the local one. This built
77
- // `http://<host>:<port>/mcp` unconditionally, so `mcp add --target cloud`
91
+ // `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
78
92
  // registered loopback with the agent: a registration that reports success,
79
93
  // names the right server, and points at a port with nothing behind it.
80
94
  const url = await endpointUrl(runtime.config, runtime.declared);
81
95
  const input: AddInput = {
82
96
  name,
83
97
  url,
84
- token,
98
+ ...(options.headless === true ? { token } : {}),
85
99
  tokenEnv,
86
100
  scope,
87
101
  profile: runtime.resolution.profile,
@@ -20,3 +20,4 @@ export { harnessCommands } from './mcp/harnesses.ts';
20
20
  export { mcpList } from './mcp/list.ts';
21
21
  export { mcpAdd, type McpAddOptions } from './mcp/register.ts';
22
22
  export { mcpStdio } from './mcp/stdio.ts';
23
+ export { installInstructions, type InstallInstructionsFlags } from './mcp/onboarding.ts';