@lanes-sh/link 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -6,11 +6,13 @@ import { loadProfileProviders } from '#providers/custom/index.ts';
6
6
  import { loadProfileSkills, type LoadedSkill } from '#providers/skills/store.ts';
7
7
  import { exampleProvider } from '#providers/example/provider.ts';
8
8
  import {
9
+ createIdentityProvider,
9
10
  createMemoryVaultStore,
10
11
  createSetupProvider,
11
12
  createSkillsProvider,
12
13
  createVaultProvider,
13
14
  memoryProvider,
15
+ type IdentityProviderOptions,
14
16
  type SetupProviderOptions,
15
17
  type VaultStore,
16
18
  } from '#providers/owner.ts';
@@ -64,6 +66,15 @@ export interface OwnerLayerOptions {
64
66
  * two capabilities that would report an empty profile as the truth.
65
67
  */
66
68
  readonly setup?: SetupProviderOptions;
69
+ /**
70
+ * Who the profile says its owner is.
71
+ *
72
+ * Absent for a registry built to read manifests, which has no config to read
73
+ * it from. The provider still registers — it reports an empty declaration
74
+ * rather than vanishing, so the difference between "nothing declared" and
75
+ * "this build has no such surface" stays visible.
76
+ */
77
+ readonly identity?: IdentityProviderOptions;
67
78
  }
68
79
 
69
80
  /**
@@ -72,7 +83,8 @@ export interface OwnerLayerOptions {
72
83
  * Statically imported for now; the registry does not care where a manifest came
73
84
  * from, which is what lets workspace YAML register alongside these.
74
85
  *
75
- * `allowReserved` is what admits `memory`, `skills`, and `vault`. The guard
86
+ * `allowReserved` is what admits `memory`, `skills`, `vault`, `setup`, and
87
+ * `identity`. The guard
76
88
  * stays rather than being retired: it exists so a *third-party* provider cannot
77
89
  * claim a namespace whose policy rules would then silently mean something else,
78
90
  * and that reason survives the owner layer shipping. Only this one construction
@@ -95,10 +107,12 @@ export function buildRegistry(owner: OwnerLayerOptions = {}): ProviderRegistry {
95
107
 
96
108
  registry.register(
97
109
  createSetupProvider(
98
- owner.setup ?? { profile: '', catalogue: PROVIDER_MANIFESTS },
110
+ owner.setup ?? { profile: '', target: '', catalogue: PROVIDER_MANIFESTS },
99
111
  ),
100
112
  );
101
113
 
114
+ registry.register(createIdentityProvider(owner.identity ?? { profile: '' }));
115
+
102
116
  for (const manifest of PROVIDERS) registry.register(manifest);
103
117
  return registry;
104
118
  }
@@ -177,6 +191,50 @@ export async function reloadSkills(
177
191
  return fingerprint;
178
192
  }
179
193
 
194
+ /**
195
+ * The skills a starting runtime should register, and their fingerprint.
196
+ *
197
+ * `tolerant` is for a skills store that is somewhere else — a repository, and
198
+ * therefore a network dependency whose failures are ordinary rather than
199
+ * exceptional: an expired token, a spent rate limit, no connectivity on a
200
+ * train. Without it, one of those takes down `openRuntime` itself, and with it
201
+ * every command in the CLI, **including the two that diagnose and undo the
202
+ * arrangement** (`lanes link doctor` and `lanes link knowledge use local`). A
203
+ * token expiring would brick the profile and hide the fix.
204
+ *
205
+ * So a store that cannot be read comes back empty and says so on the log,
206
+ * rather than throwing. This is the same trade `Generation.refreshSkills`
207
+ * already makes for the poll — the endpoint keeps serving what it has instead
208
+ * of falling over — applied to the one read that had no such guard.
209
+ *
210
+ * **A malformed skill still throws, in either mode.** That is not a store
211
+ * failure, it is a document the owner wrote and wants to hear about, and
212
+ * swallowing it would leave one skill silently missing forever.
213
+ */
214
+ export async function readSkillsForStart(
215
+ store: BlobStore,
216
+ tolerant: boolean,
217
+ warn: (message: string) => void,
218
+ /** `--profile x --target y`, so the two commands in the warning are pasteable. */
219
+ selection = '',
220
+ ): Promise<{ skills: LoadedSkill[]; fingerprint: string }> {
221
+ try {
222
+ return { skills: await loadProfileSkills(store), fingerprint: await skillFingerprint(store) };
223
+ } catch (error) {
224
+ if (!tolerant || error instanceof ConfigError) throw error;
225
+
226
+ warn(
227
+ `could not read this profile's skills: ${(error as Error).message}\n` +
228
+ ` Nothing else is affected. Run \`lanes link doctor${selection}\` for what is wrong, ` +
229
+ `or \`lanes link knowledge use local --migrate${selection}\` to bring them back onto ` +
230
+ 'this machine.',
231
+ );
232
+ // An empty fingerprint rather than one of nothing, so the next poll retries
233
+ // instead of concluding the store is empty and staying that way.
234
+ return { skills: [], fingerprint: '' };
235
+ }
236
+ }
237
+
180
238
  export async function skillFingerprint(store: BlobStore): Promise<string> {
181
239
  return (await store.list())
182
240
  .map((blob) => `${blob.key}:${blob.size}:${blob.modifiedAt.getTime()}`)
@@ -4,9 +4,10 @@ import type { BlobStore } from '#stores/blobs';
4
4
  import {
5
5
  loadProfileConfig,
6
6
  resolveSelection,
7
- resolveTarget,
7
+ requireTarget,
8
8
  undeclaredTarget,
9
9
  type Config,
10
+ type ProfileSelection,
10
11
  type Resolution,
11
12
  } from '#profile';
12
13
  import { openSecrets, openStorage } from '#deployments/target.ts';
@@ -45,23 +46,35 @@ export async function resolveProfile(
45
46
  // what means "read the real environment".
46
47
  const env = options.env !== undefined ? { env: options.env } : {};
47
48
 
48
- const selection = await resolveSelection({
49
- profileFlag: flags.profile,
50
- targetFlag: flags.target,
51
- ...env,
52
- });
49
+ const selection = await resolveSelection({ profileFlag: flags.profile, ...env });
53
50
 
54
51
  const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
55
- const { target, source } = resolveTarget(config, flags.target, {
52
+ const target = requireTarget(config, flags.target, {
56
53
  allowUndeclared: options.allowUndeclaredTarget === true,
57
- ...env,
54
+ profile: selection.profile,
58
55
  });
59
56
 
60
- return {
61
- resolution: { ...selection, target, targetSource: source },
62
- config,
63
- target,
64
- };
57
+ return { resolution: { ...selection, target }, config, target };
58
+ }
59
+
60
+ /**
61
+ * A profile without a target, for the commands that do not open one.
62
+ *
63
+ * `check` validates a YAML file, `config show` prints the whole of it, and
64
+ * `policy list` reads a block that is target-independent by construction. Making
65
+ * those three demand a `--target` would be the ceremony that teaches people to
66
+ * type `--target local` without reading it, which is how a required flag stops
67
+ * being a guard.
68
+ */
69
+ export async function resolveProfileOnly(
70
+ flags: GlobalFlags,
71
+ options: { env?: Record<string, string | undefined> } = {},
72
+ ): Promise<{ selection: ProfileSelection; config: Config }> {
73
+ const env = options.env !== undefined ? { env: options.env } : {};
74
+ const selection = await resolveSelection({ profileFlag: flags.profile, ...env });
75
+ const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
76
+
77
+ return { selection, config };
65
78
  }
66
79
 
67
80
  /**
@@ -0,0 +1,61 @@
1
+ import { layout, workspacePath } from '#profile';
2
+ import type { SecretStore } from '#secrets';
3
+ import {
4
+ createBlobVaultStore,
5
+ createFileVaultStore,
6
+ createSecretVaultStore,
7
+ type VaultStore,
8
+ } from '#providers/owner.ts';
9
+ import type { StorageFactory, TargetInput } from '#deployments/target.ts';
10
+
11
+ /**
12
+ * Which document the vault is, and where it lives.
13
+ *
14
+ * Its own file for the reason `#deployments/knowledge.ts` is: `open.ts` is a
15
+ * composition root, and "which of three backends holds one encrypted document"
16
+ * is a decision with its own reasoning rather than a step in assembling a
17
+ * runtime. Knowledge made the same move and could go to `deployments`; this one
18
+ * cannot, because the vault store is a provider and `deployments` may not import
19
+ * `providers` (`src/architecture.test.ts`). So it stops here, one level up.
20
+ */
21
+
22
+ /**
23
+ * The vault's encrypted document, wherever this target keeps it.
24
+ *
25
+ * Defaults to `file`, so a profile written before ADR-014 needs no change and a
26
+ * local run needs no vault configuration at all. `blob` exists because the file
27
+ * adapter was unconditional before: a deployed instance wrote its vault to a
28
+ * container filesystem, and every item in it was discarded by the next
29
+ * revision without an error to say so.
30
+ */
31
+ export function openVault(
32
+ input: TargetInput,
33
+ storage: StorageFactory,
34
+ credentials: SecretStore,
35
+ ): VaultStore {
36
+ const { declared, config, root } = input;
37
+ const vault = declared.vault ?? { adapter: 'file' as const };
38
+
39
+ switch (vault.adapter) {
40
+ case 'file':
41
+ return createFileVaultStore({
42
+ path: workspacePath(root, vault.path ?? layout.vault(config.instance.profile)),
43
+ });
44
+
45
+ case 'secret':
46
+ // The document is sealed under LANES_LINK_VAULT_KEY before it gets here,
47
+ // so the credential store holds ciphertext it cannot read. Separate
48
+ // document, separate key, separate environment variable — the backend
49
+ // was never what kept the two stores apart. ADR-022.
50
+ return createSecretVaultStore({
51
+ store: credentials,
52
+ ...(vault.ref !== undefined ? { ref: vault.ref } : {}),
53
+ });
54
+
55
+ case 'blob':
56
+ return createBlobVaultStore({
57
+ store: storage(),
58
+ ...(vault.path !== undefined ? { key: vault.path } : {}),
59
+ });
60
+ }
61
+ }
@@ -22,6 +22,7 @@ export {
22
22
  openSecretStoreFor,
23
23
  ownerPrincipal,
24
24
  resolveProfile,
25
+ resolveProfileOnly,
25
26
  type GlobalFlags,
26
27
  } from './runtime/select.ts';
27
28
 
@@ -31,4 +32,4 @@ export {
31
32
  type OwnerLayerOptions,
32
33
  } from './runtime/registry.ts';
33
34
 
34
- export { openRuntime, type Runtime } from './runtime/open.ts';
35
+ export { openRuntime, type OpenOptions, type Runtime } from './runtime/open.ts';
@@ -0,0 +1,365 @@
1
+ import {
2
+ ConfigError,
3
+ listProfiles,
4
+ loadProfileConfig,
5
+ loadWorkspaceProfiles,
6
+ noProfileNamed,
7
+ noTargetInWorkspace,
8
+ noTargetNamed,
9
+ resolveWorkspaceRoot,
10
+ targetsByName,
11
+ } from '#profile';
12
+ import type { Flags } from './argv.ts';
13
+ import { nearest } from './nearest.ts';
14
+
15
+ /**
16
+ * Which commands must name a profile and a target, and which flags each accepts.
17
+ *
18
+ * Two rules, in one file because they fail for the same reason and the fix for
19
+ * one makes the other legible.
20
+ *
21
+ * **A flag that is silently ignored is the defect.** `lanes link profile add
22
+ * work --target cloud` printed `ok` and dropped the flag: `main.ts` built a
23
+ * literal for that command and never spread the global flags into it. Nothing
24
+ * refused, because nothing had a list of what the command accepts. That is what
25
+ * `assertKnownFlags` is — and it matters more than the requirement, because
26
+ * required flags make a typo *worse* on their own. `--porfile work` used to
27
+ * fall through to a workspace default and mostly work; with a requirement and
28
+ * no allowlist it produces "--profile is required", naming a flag the operator
29
+ * believes they just passed.
30
+ *
31
+ * **A selection is named or the command does not run** (ADR-037). The table
32
+ * below is the whole rule, and `selection.test.ts` reads `main.ts` to check that
33
+ * every dispatched command appears in it — so a new command cannot quietly
34
+ * default to requiring nothing.
35
+ */
36
+
37
+ /**
38
+ * What a command must be told before it can act.
39
+ *
40
+ * `target` is not a weaker `profile+target`. It says the command's subject *is*
41
+ * the target, and that the profiles behind it are every profile declaring it
42
+ * rather than one the operator picks (ADR-043). `--profile` stays accepted
43
+ * there, as a filter.
44
+ */
45
+ export type Requires = 'none' | 'profile' | 'target' | 'profile+target';
46
+
47
+ /**
48
+ * The rule, per command path.
49
+ *
50
+ * `--profile` for anything that reads or writes a profile's config or stores.
51
+ * `--target` for anything that opens a target's adapters or acts against a
52
+ * target's endpoint. A command that names a target positionally or through
53
+ * `--from`/`--to` supplies it that way and is not asked twice.
54
+ *
55
+ * Three entries are worth defending, because uniformity would be wrong:
56
+ *
57
+ * `check`, `config show` and `policy list` take no `--target`. All three are
58
+ * target-independent — a YAML file, the whole of it, and a policy block that is
59
+ * declared once and applies everywhere. Demanding a target would be the
60
+ * ceremony that teaches people to type `--target local` without reading it,
61
+ * which is how a required flag stops being a guard.
62
+ *
63
+ * `target list` takes no required `--target` either, and that is not an
64
+ * oversight: it is the command you run to find out what to pass. Requiring the
65
+ * answer as input is circular, and it has to keep working in the state every
66
+ * other command fails in.
67
+ *
68
+ * `status`, `deploy` and `sync targets` take `target` rather than
69
+ * `profile+target`. One deployed endpoint serves every profile in the workspace
70
+ * (ADR-009), so the profile set behind a target is enumerable from the config
71
+ * and naming one of them describes a slice, not the subject. That is not the
72
+ * inference ADR-037 removed: there is nothing to guess at, and nothing is
73
+ * silently chosen.
74
+ *
75
+ * `profile add` and `profile remove` **reject** `--profile`. Both name their
76
+ * profile positionally, so a flag naming a second one could only disagree with
77
+ * it. `add` has no profile to select before it exists; `remove` takes an
78
+ * optional `--target` to decommission one target's stores and keep the file.
79
+ */
80
+ export const SELECTION: Record<string, Requires> = {
81
+ help: 'none',
82
+ version: 'none',
83
+ update: 'none',
84
+ skill: 'none',
85
+ 'mcp skill': 'none',
86
+ 'mcp list': 'none',
87
+ // The bare forms, which each dispatch to a `case undefined` in `main.ts`.
88
+ // `lanes link profile` is `profile list`, and needs the same as it.
89
+ profile: 'none',
90
+ mcp: 'none',
91
+ 'profile list': 'none',
92
+ 'profile add': 'none',
93
+ 'profile default': 'none',
94
+ 'target use': 'none',
95
+ 'vault key': 'none',
96
+
97
+ check: 'profile',
98
+ config: 'profile',
99
+ policy: 'profile',
100
+ target: 'profile',
101
+ 'config show': 'profile',
102
+ 'policy list': 'profile',
103
+ 'target list': 'profile',
104
+ 'target show': 'profile',
105
+ 'secrets push': 'profile',
106
+ 'profile remove': 'none',
107
+ // Target-independent for the same reason `policy list` is: the block is
108
+ // declared once in the YAML and applies to every target the profile has.
109
+ identity: 'profile',
110
+ 'identity list': 'profile',
111
+
112
+ connect: 'profile+target',
113
+ setup: 'profile+target',
114
+ token: 'profile+target',
115
+ audit: 'profile+target',
116
+ secrets: 'profile+target',
117
+ plan: 'profile+target',
118
+ doctor: 'profile+target',
119
+ // Target-scoped: see the note above. `--profile` narrows each to one profile.
120
+ status: 'target',
121
+ outputs: 'profile+target',
122
+ tools: 'profile+target',
123
+ // It reads which target it is rendering for before it decides anything: a
124
+ // deployed one has no page to open, and the refusal has to name it.
125
+ dashboard: 'profile+target',
126
+ attach: 'profile+target',
127
+ start: 'profile+target',
128
+ deploy: 'target',
129
+ // Both spellings: `sync` alone is `sync targets`, which is the only thing
130
+ // there is to sync, and naming it leaves room for the next one.
131
+ sync: 'target',
132
+ 'sync targets': 'target',
133
+ 'policy allow': 'profile+target',
134
+ 'policy deny': 'profile+target',
135
+ // Both, unlike `identity list`, and for the same reason the policy edits are:
136
+ // each publishes the edit, which opens the target's credential store and
137
+ // reaches that target's endpoint.
138
+ 'identity add': 'profile+target',
139
+ 'identity remove': 'profile+target',
140
+ 'token show': 'profile+target',
141
+ 'token rotate': 'profile+target',
142
+ 'audit tail': 'profile+target',
143
+ 'audit verify': 'profile+target',
144
+ 'secrets set': 'profile+target',
145
+ 'secrets list': 'profile+target',
146
+ 'mcp add': 'profile+target',
147
+ 'mcp stdio': 'profile+target',
148
+ memory: 'profile+target',
149
+ skills: 'profile+target',
150
+ vault: 'profile+target',
151
+ // Both halves open the target's adapters — `show` counts what is in the
152
+ // stores, and `use` migrates between them — and both edit the profile's
153
+ // config. Neither can be answered without being told which.
154
+ knowledge: 'profile+target',
155
+ };
156
+
157
+ /**
158
+ * The second words each command accepts.
159
+ *
160
+ * Only the commands that have subcommands appear. This exists for one reason:
161
+ * the checks below run before the switch, so without it `lanes link vault bogus`
162
+ * is refused for a missing `--profile` rather than for the subcommand that does
163
+ * not exist — a usage error reported as the wrong usage error, which is its own
164
+ * small version of the bug being fixed. `selection.test.ts` reads `main.ts` and
165
+ * asserts this stays true.
166
+ */
167
+ const SUBCOMMANDS: Record<string, readonly string[]> = {
168
+ profile: ['add', 'list', 'default', 'remove'],
169
+ target: ['list', 'use', 'show'],
170
+ policy: ['list', 'allow', 'deny'],
171
+ identity: ['add', 'list', 'remove'],
172
+ token: ['show', 'rotate'],
173
+ audit: ['tail', 'verify'],
174
+ config: ['show'],
175
+ setup: ['plan'],
176
+ memory: ['list', 'get', 'write', 'forget'],
177
+ skills: ['list', 'show', 'add', 'remove'],
178
+ vault: ['list', 'get', 'set', 'remove', 'key'],
179
+ mcp: ['skill', 'add', 'stdio', 'list'],
180
+ secrets: ['push', 'set', 'list'],
181
+ knowledge: ['show', 'use'],
182
+ sync: ['targets'],
183
+ };
184
+
185
+ /**
186
+ * Whether the switch is going to refuse this command path anyway.
187
+ *
188
+ * When it is, these checks stay quiet and let it: "Unknown: lanes link vault
189
+ * bogus" is the useful sentence, and a complaint about `--profile` on a command
190
+ * that does not exist sends someone off to fix the wrong thing.
191
+ */
192
+ function dispatchWillRefuse(first: string, second: string | undefined): boolean {
193
+ const known = SUBCOMMANDS[first];
194
+ if (!known || second === undefined) return false;
195
+ return !known.includes(second);
196
+ }
197
+
198
+ /**
199
+ * The key for a command, longest match first.
200
+ *
201
+ * `token show` before `token`, so a two-word command can differ from its
202
+ * siblings without every sibling having to be listed.
203
+ */
204
+ export function selectionKey(first: string, second: string | undefined): string {
205
+ const pair = second ? `${first} ${second}` : first;
206
+ if (pair in SELECTION) return pair;
207
+ return first;
208
+ }
209
+
210
+ /** Whether this command needs a profile, a target, both, or neither. */
211
+ export function requirementFor(first: string, second: string | undefined): Requires {
212
+ return SELECTION[selectionKey(first, second)] ?? 'profile+target';
213
+ }
214
+
215
+ /**
216
+ * Refuse before the command runs, naming what it wants and what there is.
217
+ *
218
+ * Async, and it reads the workspace — but only on the way to throwing. The
219
+ * useful half of "which profile did you mean" is the list of them, and the same
220
+ * for targets; a refusal that only restates the flag name leaves someone to go
221
+ * and look it up. Both messages come from `#profile` so this file and the
222
+ * resolver cannot describe the same refusal differently, and both name an
223
+ * exported variable that no longer counts — the shell still configured for the
224
+ * old world is the state hardest to diagnose from the inside.
225
+ */
226
+ export async function requireSelection(
227
+ first: string,
228
+ second: string | undefined,
229
+ flags: Flags,
230
+ env?: Record<string, string | undefined>,
231
+ ): Promise<void> {
232
+ if (dispatchWillRefuse(first, second)) return;
233
+
234
+ const needs = requirementFor(first, second);
235
+ if (needs === 'none') return;
236
+
237
+ // Asked before the profile requirement, because for these there is none. The
238
+ // refusal has to describe the workspace rather than one profile's targets,
239
+ // since the command was never going to act on only one.
240
+ if (needs === 'target') {
241
+ if (typeof flags['target'] === 'string') return;
242
+ const root = resolveWorkspaceRoot(env ? { env } : {});
243
+ throw noTargetInWorkspace(targetsByName(await loadWorkspaceProfiles(root)), root, env);
244
+ }
245
+
246
+ const profile = flags['profile'];
247
+ if (typeof profile !== 'string') {
248
+ const root = resolveWorkspaceRoot(env ? { env } : {});
249
+ throw noProfileNamed(root, await listProfiles(root), env);
250
+ }
251
+
252
+ if (needs !== 'profile+target' || typeof flags['target'] === 'string') return;
253
+
254
+ // The profile is known by here, so the target list is the one belonging to it
255
+ // rather than a guess. A profile that does not exist is a different refusal,
256
+ // and `resolveSelection` gives it a better one a moment later.
257
+ const root = resolveWorkspaceRoot(env ? { env } : {});
258
+ try {
259
+ const { config } = await loadProfileConfig(root, profile);
260
+ throw noTargetNamed(config, profile, env);
261
+ } catch (error) {
262
+ if (error instanceof ConfigError) throw error;
263
+ throw new ConfigError(`--target is required for "${[first, second].filter(Boolean).join(' ')}".`);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Flags every command accepts, whatever it does.
269
+ *
270
+ * `--help` short-circuits before dispatch, and `--json` is offered widely enough
271
+ * that listing it per command would be noise. `--quiet` is read by `announce`
272
+ * rather than by any one command.
273
+ */
274
+ const UNIVERSAL = ['help', 'json', 'quiet'];
275
+
276
+ /**
277
+ * What each command accepts beyond the universal set and its own selection.
278
+ *
279
+ * Only commands with flags of their own appear. Anything absent accepts the
280
+ * universal set plus whatever `SELECTION` says it must be told.
281
+ */
282
+ const ACCEPTS: Record<string, readonly string[]> = {
283
+ // `own-client` is the older spelling of one of the routes `auth` names, kept
284
+ // because it is in scripts and a year of documentation (ADR-038).
285
+ connect: [
286
+ 'id',
287
+ 'display-name',
288
+ 'replace',
289
+ 'non-interactive',
290
+ 'accept-broad-scopes',
291
+ 'own-client',
292
+ 'auth',
293
+ ],
294
+ setup: ['id'],
295
+ 'profile add': ['target', 'non-interactive'],
296
+ // `--target` decommissions one target's stores and leaves the profile file in
297
+ // place (`removal.ts`). It is documented in `usage.ts` and read by
298
+ // `removalPlan`, and was refused here — the flag existed everywhere except in
299
+ // the list that decides whether it may be typed.
300
+ 'profile remove': ['dry-run', 'yes', 'target'],
301
+ 'target list': ['urls', 'target'],
302
+ 'target show': ['target'],
303
+ 'token show': ['show', 'raw'],
304
+ 'token rotate': ['show', 'raw', 'yes'],
305
+ 'audit tail': ['limit', 'denied-only', 'format'],
306
+ 'audit verify': ['limit', 'format'],
307
+ attach: ['connection'],
308
+ outputs: ['show'],
309
+ start: ['port', 'only'],
310
+ 'mcp stdio': ['only'],
311
+ 'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
312
+ 'mcp skill': ['print', 'force'],
313
+ 'mcp list': ['name', 'scope'],
314
+ dashboard: ['print'],
315
+ skill: ['print', 'force'],
316
+ deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
317
+ 'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
318
+ sync: ['dry-run', 'from', 'discover', 'prefer'],
319
+ 'sync targets': ['dry-run', 'from', 'discover', 'prefer'],
320
+ update: ['check'],
321
+ 'identity add': ['note'],
322
+ memory: ['connection', 'title', 'description', 'file', 'tag'],
323
+ skills: ['connection', 'title', 'description', 'file'],
324
+ vault: ['connection'],
325
+ // `no-migrate` is listed beside `migrate` because they are three states
326
+ // rather than two: neither one asks, and a run with no terminal has to be
327
+ // able to say which it meant (ADR-041).
328
+ knowledge: ['repo', 'branch', 'path', 'migrate', 'no-migrate', 'keep', 'allow-public', 'replace', 'yes'],
329
+ };
330
+
331
+ /**
332
+ * Refuse a flag this command does not read, and guess what was meant.
333
+ *
334
+ * This is the fix for the reported bug rather than a nicety. `profile add
335
+ * --target cloud` was accepted and dropped, and nothing could refuse it because
336
+ * `parseArgv` returns every `--anything` it sees and no command ever inspected
337
+ * the leftovers. A typo was swallowed the same way on every command in the CLI.
338
+ */
339
+ export function assertKnownFlags(first: string, second: string | undefined, flags: Flags): void {
340
+ if (dispatchWillRefuse(first, second)) return;
341
+
342
+ const key = selectionKey(first, second);
343
+ const needs = SELECTION[key] ?? 'profile+target';
344
+
345
+ const allowed = new Set<string>([
346
+ ...UNIVERSAL,
347
+ ...(ACCEPTS[key] ?? []),
348
+ // `target` accepts both: the target is what it acts on, and `--profile`
349
+ // narrows it to one of the profiles behind it.
350
+ ...(needs !== 'none' ? ['profile'] : []),
351
+ ...(needs === 'target' || needs === 'profile+target' ? ['target'] : []),
352
+ ]);
353
+
354
+ const named = [first, second].filter(Boolean).join(' ');
355
+
356
+ for (const given of Object.keys(flags)) {
357
+ if (allowed.has(given)) continue;
358
+
359
+ throw new ConfigError(
360
+ `Unknown flag "--${given}" for "lanes link ${named}".` +
361
+ (nearest(given, allowed) ? `\n Did you mean --${nearest(given, allowed)}?` : '') +
362
+ `\n Accepts: ${[...allowed].sort().map((name) => `--${name}`).join(' ')}`,
363
+ );
364
+ }
365
+ }