@lanes-sh/link 0.2.1 → 0.3.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 (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -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/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,284 @@
1
+ import { PROVIDER_MANIFESTS } from '#providers/index.ts';
2
+ import { planAll, type ProviderPlan } from '#providers/setup/plan.ts';
3
+ import { PROVIDER_MARKS } from './provider-marks.ts';
4
+ import { escapeHtml } from './brand.ts';
5
+ import { shell } from './dashboard-shell.ts';
6
+
7
+ /**
8
+ * The dashboard — one page answering what `status`, `setup plan` and
9
+ * `target list` answer between them.
10
+ *
11
+ * It renders and it does not act. Every state-changing thing on it is a command
12
+ * to paste, for two reasons that are not the same. Connecting is ADR-005: the
13
+ * consent belongs to whoever owns the browser, and the loopback listener that
14
+ * receives the code is the CLI's. Everything else is ADR-007: config is written
15
+ * from the control plane, and a page served by the endpoint is not it.
16
+ *
17
+ * So this is a reader, and the honest shape of a reader is a page that tells you
18
+ * the line to run. What it saves is not typing — it is knowing which line.
19
+ *
20
+ * The design is `callback-page.ts`'s, because that is the product's page and
21
+ * this is the same product: nothing painted (`color-scheme: light dark` over a
22
+ * transparent background, correct in either mode with no theme switch), Lora on
23
+ * the heading, and colour reserved for status rather than spent on emphasis.
24
+ * What differs is the width — a card centred at 460px is right for one sentence
25
+ * and wrong for four sections.
26
+ */
27
+
28
+ export interface DashboardConnection {
29
+ /** `provider.id`, the key everything else addresses this by. */
30
+ readonly key: string;
31
+ readonly provider: string;
32
+ readonly account: string;
33
+ /** `active`, `unauthorized`, `disabled`, or `not reconciled`. */
34
+ readonly state: string;
35
+ }
36
+
37
+ export interface DashboardView {
38
+ /** The profile being rendered, which `?profile=` may have chosen. */
39
+ readonly profile: string;
40
+ /** Every profile this endpoint serves, for the switcher. */
41
+ readonly profiles: readonly string[];
42
+ /** The target whose adapters are open. */
43
+ readonly target: string;
44
+ /** Every target the profile declares, live or not. */
45
+ readonly targets: readonly string[];
46
+ readonly connections: readonly DashboardConnection[];
47
+ /** `oauth_apps` keys, so a profile with its own client is described as having one. */
48
+ readonly ownClients: readonly string[];
49
+ }
50
+
51
+ /**
52
+ * Every command this page renders, built in one place.
53
+ *
54
+ * `--profile` and `--target` on all of them, unconditionally. The shell a line
55
+ * is pasted into resolves both for itself — from `LANES_LINK_PROFILE` and
56
+ * `LANES_LINK_TARGET`, or from the workspace default — and a page that shows
57
+ * you one profile while handing you a command that silently acts on another is
58
+ * worse than one that shows nothing. It is also the rule `resolveSelection`
59
+ * already follows by refusing to guess.
60
+ */
61
+ function command(view: DashboardView, rest: string): string {
62
+ return `lanes link ${rest} --profile ${view.profile} --target ${view.target}`;
63
+ }
64
+
65
+ /**
66
+ * A command, shown in full.
67
+ *
68
+ * Kept for the two places where the command *is* the label — the sign-in page
69
+ * and the footer — because there is no provider name to carry it. Everywhere
70
+ * else the line is on the button, not on the page; see `copyButton`.
71
+ */
72
+ function commandLine(line: string): string {
73
+ const text = escapeHtml(line);
74
+ return (
75
+ `<div class="cmd"><code>${text}</code>` +
76
+ `<button class="btn copy" type="button" data-copy="${text}" aria-label="Copy command">copy</button></div>`
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The command, on the button rather than beside it.
82
+ *
83
+ * What this trades away is worth naming: the line is no longer selectable by
84
+ * hand, so a browser with no clipboard API leaves no way to get it. That is
85
+ * narrower than it sounds — `navigator.clipboard` needs a secure context, and
86
+ * `http://127.0.0.1` is one by definition, which is the only address this page
87
+ * is ever served on. The `title` carries the text for anyone who wants to read
88
+ * before pasting, and `lanes link setup plan` prints the same lines.
89
+ */
90
+ function copyButton(line: string, label: string): string {
91
+ const text = escapeHtml(line);
92
+ return (
93
+ `<button class="btn copy" type="button" data-copy="${text}" title="${text}" ` +
94
+ `aria-label="Copy the command that connects ${escapeHtml(label)}">copy</button>`
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The provider's mark, or letters standing in for one.
100
+ *
101
+ * `aria-hidden` on both: every caller puts the name beside it, and a screen
102
+ * reader announcing "GitHub, GitHub" is worse than one announcing it once.
103
+ *
104
+ * The fallback takes the first letter of the family and two of the member,
105
+ * because the plain two collapse a catalogue holding four `icloud_*` entries
106
+ * and two `*_mcp` ones into a handful of identical marks — `ICA` and `ICO`
107
+ * rather than `IC` twice.
108
+ */
109
+ function mark(id: string): string {
110
+ const path = PROVIDER_MARKS[id];
111
+ if (path) {
112
+ return (
113
+ '<svg class="glyph" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
114
+ `<path d="${path}"/></svg>`
115
+ );
116
+ }
117
+
118
+ const [family, member] = id.split('_');
119
+ const letters = member ? `${family![0]}${member.slice(0, 2)}` : id.slice(0, 2);
120
+ return `<span class="glyph letters" aria-hidden="true">${escapeHtml(letters.toUpperCase())}</span>`;
121
+ }
122
+
123
+ /**
124
+ * The state, as one of the design system's badge variants.
125
+ *
126
+ * "Gold for positive, neutral tokens otherwise" — so `active` is the only gold
127
+ * one, and `unauthorized` is *neutral* rather than red. It is not an error: the
128
+ * credential is absent from this target's store, which is a thing to do, and
129
+ * `--destructive` is reserved for something having gone wrong. `disabled` is
130
+ * quieter still, being a row that config no longer declares.
131
+ */
132
+ function statusPill(state: string): string {
133
+ const kind = state === 'active' ? 'positive' : state === 'disabled' ? 'quiet' : 'neutral';
134
+ return `<span class="pill ${kind}">${escapeHtml(state)}</span>`;
135
+ }
136
+
137
+ function connectionsSection(view: DashboardView): string {
138
+ if (view.connections.length === 0) {
139
+ return '<p class="empty">Nothing connected in this profile yet. Pick one below.</p>';
140
+ }
141
+
142
+ const rows = view.connections
143
+ .map((connection) => {
144
+ // A connection that is not active is one the reader can act on, and the
145
+ // action is the same `connect` that made it — re-running against an
146
+ // existing key repairs it rather than adding a second account. An active
147
+ // one has nothing to do, so it carries no button.
148
+ const repair =
149
+ connection.state === 'active'
150
+ ? ''
151
+ : copyButton(command(view, `connect ${connection.key}`), connection.key);
152
+
153
+ return (
154
+ '<div class="row">' +
155
+ mark(connection.provider) +
156
+ `<code class="key">${escapeHtml(connection.key)}</code>` +
157
+ statusPill(connection.state) +
158
+ `<span class="account">${escapeHtml(connection.account)}</span>` +
159
+ repair +
160
+ '</div>'
161
+ );
162
+ })
163
+ .join('\n');
164
+
165
+ return `<div class="rows">${rows}</div>`;
166
+ }
167
+
168
+ /**
169
+ * One provider, one line: what it is called and the line that connects it.
170
+ *
171
+ * Everything else a card used to carry is gone — the description, whether it
172
+ * opens a browser, who operates its OAuth client. None of it is lost: it is all
173
+ * in `lanes link setup plan` and in the `setup_provider` capability, both of
174
+ * which exist to answer "what does connecting this involve" at the length that
175
+ * question deserves. This page answers a different one — what is there, and
176
+ * what would I paste — and a paragraph per provider buried it.
177
+ *
178
+ * `plan.command` is built by the same `planFor` those two render, so the line
179
+ * copied here and the line an agent suggests cannot drift.
180
+ */
181
+ function providerCard(plan: ProviderPlan): string {
182
+ return (
183
+ '<div class="row">' +
184
+ mark(plan.id) +
185
+ `<span class="name">${escapeHtml(plan.name)}</span>` +
186
+ copyButton(plan.command, plan.name) +
187
+ '</div>'
188
+ );
189
+ }
190
+
191
+ function catalogue(view: DashboardView): { available: string; another: string } {
192
+ const plans = planAll(PROVIDER_MANIFESTS, {
193
+ profile: view.profile,
194
+ connections: view.connections.map((connection) => connection.key),
195
+ ownClients: view.ownClients,
196
+ target: view.target,
197
+ });
198
+
199
+ // The same split `setup_overview` makes: never connected is an invitation,
200
+ // and already connected is only an invitation when a second account means
201
+ // something (ADR — `multiAccount` is the credential test, not a preference).
202
+ const available = plans.filter((plan) => plan.connected.length === 0);
203
+ const more = plans.filter((plan) => plan.connected.length > 0 && plan.multiAccount);
204
+
205
+ return {
206
+ available: available.map(providerCard).join('\n'),
207
+ another: more.map(providerCard).join('\n'),
208
+ };
209
+ }
210
+
211
+ function switcher(view: DashboardView): string {
212
+ // Links, not a form: choosing a profile here changes what this page shows and
213
+ // nothing else. The workspace default is config, and config is written from
214
+ // the CLI.
215
+ const profiles = view.profiles
216
+ .map((name) => {
217
+ const current = name === view.profile;
218
+ const label = escapeHtml(name);
219
+ return current
220
+ ? `<span class="chip on">${label}</span>`
221
+ : `<a class="chip" href="?profile=${encodeURIComponent(name)}">${label}</a>`;
222
+ })
223
+ .join('');
224
+
225
+ // Every declared target, with the one whose adapters are actually open marked.
226
+ // The others are real — they are where `--target` would write — but nothing on
227
+ // this page is reading from them.
228
+ const targets = view.targets
229
+ .map((name) =>
230
+ name === view.target
231
+ ? `<span class="chip on">${escapeHtml(name)}</span>`
232
+ : `<span class="chip off" title="declared, but not the one this endpoint opened">${escapeHtml(name)}</span>`,
233
+ )
234
+ .join('');
235
+
236
+ return (
237
+ '<div class="switch">' +
238
+ `<div class="group"><span class="eyebrow">profile</span>${profiles}</div>` +
239
+ `<div class="group"><span class="eyebrow">target</span>${targets}</div>` +
240
+ '</div>'
241
+ );
242
+ }
243
+
244
+ export function dashboardPage(view: DashboardView): Response {
245
+ const { available, another } = catalogue(view);
246
+
247
+ const body =
248
+ `<h1>Lanes Link</h1>${switcher(view)}` +
249
+ `<h2 class="eyebrow">Connections</h2>${connectionsSection(view)}` +
250
+ (available ? `<h2 class="eyebrow">Available</h2><div class="rows">${available}</div>` : '') +
251
+ (another ? `<h2 class="eyebrow">Connect another account</h2><div class="rows">${another}</div>` : '') +
252
+ '<h2 class="eyebrow">Elsewhere</h2>' +
253
+ '<p class="empty">What is reachable, and what is wrong with it:</p>' +
254
+ commandLine(command(view, 'status')) +
255
+ commandLine(command(view, 'doctor'));
256
+
257
+ return shell(body, 'Lanes Link', 200);
258
+ }
259
+
260
+ /**
261
+ * What an unauthenticated browser gets.
262
+ *
263
+ * The narrow card rather than the wide page, because it is one sentence — and
264
+ * it names the command instead of asking for the token, since the command is
265
+ * what puts the token in the URL in the first place. A password field here
266
+ * would be a second way in to guard for no benefit: whoever can run the command
267
+ * is already whoever the token would prove them to be.
268
+ *
269
+ * The one command on this page carries neither `--profile` nor `--target`,
270
+ * against the rule every other command here follows. Both are names, and this
271
+ * page answers before authentication — ADR-018 stopped `/health` naming
272
+ * profiles to an anonymous caller for exactly that reason, and a 401 that
273
+ * recites the workspace's profile list would put it back.
274
+ */
275
+ export function dashboardSignInPage(status: number): Response {
276
+ return shell(
277
+ '<div class="narrow"><h1>Not signed in</h1>' +
278
+ '<p class="desc">This page opens from the terminal that is serving it.</p>' +
279
+ commandLine('lanes link dashboard') +
280
+ '</div>',
281
+ 'Lanes Link',
282
+ status,
283
+ );
284
+ }
@@ -0,0 +1,125 @@
1
+ import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
2
+
3
+ /**
4
+ * The dashboard's chrome: its layout, its one listener, and the document they
5
+ * hang in.
6
+ *
7
+ * Split from `dashboard-page.ts` because the two answer different questions —
8
+ * that file decides what a row says, this one decides what a row looks like and
9
+ * what the response is allowed to load. Everything shared with the two
10
+ * authorization pages, which is every colour and every face, is in `brand.ts`;
11
+ * what is left here is the one thing this surface does not share with them, a
12
+ * wide list rather than a centred card.
13
+ */
14
+
15
+ const STYLE = `
16
+ body { padding: 40px 24px; }
17
+ .wrap { width: 100%; max-width: 620px; margin: 0 auto; }
18
+ .narrow { text-align: center; padding: 32px 0; }
19
+ h1 { margin: 0 0 20px; }
20
+ h2 { margin: 30px 0 8px; }
21
+ .desc { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground); }
22
+ .empty { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground);
23
+ opacity: 0.8; }
24
+
25
+ /* Profile and target selectors. The current one is not gold: gold says a thing
26
+ is good, and which profile you are looking at is not a verdict. */
27
+ .switch { display: flex; flex-wrap: wrap; gap: 20px; padding-bottom: 4px; }
28
+ .group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
29
+ .chip { display: inline-block; padding: 3px 10px; font-size: 13px; border-radius: 999px;
30
+ border: 1px solid var(--border); color: var(--muted-foreground); text-decoration: none; }
31
+ .chip:hover { color: var(--foreground); background: var(--muted); }
32
+ .chip.on { color: var(--foreground); background: var(--muted); font-weight: 500; }
33
+ .chip.off { border-style: dashed; opacity: 0.5; }
34
+
35
+ /* One line per item: mark, name, whatever qualifies it, then the button hard
36
+ right. \`min-width: 0\` on the row lets the name ellipsize rather than push
37
+ the button off the end. */
38
+ .rows { display: flex; flex-direction: column; }
39
+ .row { display: flex; align-items: center; gap: 10px; min-width: 0;
40
+ padding: 7px 10px 7px 4px; border-radius: 6px; }
41
+ .row:hover { background: var(--muted); }
42
+ .glyph { flex: none; display: inline-flex; align-items: center; justify-content: center;
43
+ width: 17px; height: 17px; margin: 0 3px; opacity: 0.85; }
44
+ /* The stand-in wants the box the real marks do not: bare letters read as a mark
45
+ that failed to load, where a brand mark reads as itself. */
46
+ .glyph.letters { width: 23px; height: 23px; margin: 0; border-radius: 6px; font-size: 8.5px;
47
+ font-weight: 500; letter-spacing: 0.02em; color: var(--muted-foreground);
48
+ border: 1px solid var(--border); opacity: 1; }
49
+ .name, .key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
+ .name { font-size: 14px; }
51
+ .key { font-family: var(--mono); font-size: 13px; }
52
+ .account { font-size: 12.5px; color: var(--muted-foreground); opacity: 0.8; min-width: 0;
53
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54
+
55
+ /* Always visible, never revealed on hover: the button is the only way to get the
56
+ command now that the line itself is not on the page, and an affordance you
57
+ have to find by sweeping the mouse is one most readers never find. */
58
+ .copy { margin-left: auto; opacity: 0.6; }
59
+ .row:hover .copy { opacity: 1; }
60
+ .copy:hover, .copy:focus-visible { opacity: 1; }
61
+
62
+ /* The two places the command is still the label, so it stays visible. */
63
+ .cmd { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 10px;
64
+ background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
65
+ .cmd code { flex: 1; font-size: 12.5px; line-height: 1.5; word-break: break-all; }
66
+ .cmd .copy { opacity: 0.8; margin-left: 0; }
67
+ `.trim();
68
+
69
+ /**
70
+ * The copy button, and nothing else.
71
+ *
72
+ * Inline because a separate file would be the first static asset this
73
+ * repository serves, and one listener is not worth that. It reads the command
74
+ * from a data attribute rather than from the DOM text so that what is copied is
75
+ * exactly what was rendered, whitespace included.
76
+ */
77
+ const SCRIPT = `
78
+ document.addEventListener('click', function (event) {
79
+ var button = event.target.closest('.copy');
80
+ if (!button || !navigator.clipboard) return;
81
+ navigator.clipboard.writeText(button.dataset.copy).then(function () {
82
+ button.textContent = 'copied';
83
+ setTimeout(function () { button.textContent = 'copy'; }, 1200);
84
+ });
85
+ });
86
+ `.trim();
87
+
88
+ export function shell(inner: string, title: string, status: number): Response {
89
+ return new Response(
90
+ `<!doctype html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="utf-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1">
95
+ ${FONTS}
96
+ <title>${escapeHtml(title)}</title>
97
+ <style>
98
+ ${TOKENS}
99
+ ${STYLE}
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <div class="wrap">
104
+ ${inner}
105
+ ${FOOTER}
106
+ </div>
107
+ <script>
108
+ ${SCRIPT}
109
+ </script>
110
+ </body>
111
+ </html>`,
112
+ {
113
+ status,
114
+ headers: {
115
+ ...PAGE_HEADERS,
116
+ // The shared policy plus the one thing this page has that the others do
117
+ // not: an inline listener, for the copy button.
118
+ 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'`,
119
+ // Connection keys and account names are not something to leave in a
120
+ // shared browser's back-forward cache.
121
+ 'cache-control': 'no-store',
122
+ },
123
+ },
124
+ );
125
+ }
@@ -58,7 +58,18 @@ export async function resolveAccount(
58
58
  headers: token ? { authorization: `Bearer ${token}` } : {},
59
59
  });
60
60
  if (!response.ok) return null;
61
- return pluck(await response.json(), identity.field);
61
+
62
+ const body = await response.json();
63
+ const primary = pluck(body, identity.field);
64
+ if (!primary || !identity.qualifier) return primary;
65
+
66
+ // `alice (Acme)` rather than `alice`. The bracketed half is what makes
67
+ // two workspaces two accounts instead of one overwritten twice, and it
68
+ // survives into the connection id because `idFromAccount` slugifies the
69
+ // whole string when there is no `@` in it — `alice_acme`, which is a row
70
+ // somebody can read in `status`.
71
+ const qualifier = pluck(body, identity.qualifier);
72
+ return qualifier ? `${primary} (${qualifier})` : primary;
62
73
  }
63
74
 
64
75
  if (!probe.callTool) return null;
package/src/cli/main.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  auditVerify,
6
6
  check,
7
7
  configShow,
8
+ dashboard,
8
9
  doctor,
9
10
  outputs,
10
11
  plan,
@@ -19,6 +20,7 @@ import {
19
20
  import { profileAdd, profileDefault, profileList } from './commands/profile.ts';
20
21
  import { removeProfile as profileRemove } from './commands/profile/remove.ts';
21
22
  import { targetList, targetShow, targetUse } from './commands/target.ts';
23
+ import { identityAdd, identityList, identityRemove } from './commands/identity.ts';
22
24
  import { setupPlan } from './commands/setup.ts';
23
25
  import { mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
24
26
  import { deploy } from '#deployments/deploy.ts';
@@ -38,8 +40,10 @@ import {
38
40
  vaultRemove,
39
41
  vaultSet,
40
42
  } from './commands/owner.ts';
43
+ import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
41
44
  import { update } from './commands/update.ts';
42
- import { globalFlags, ownerFlags, parseArgv, text } from './argv.ts';
45
+ import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
46
+ import { assertKnownFlags, requireSelection } from './selection.ts';
43
47
  import { PROGRAM, USAGE } from './usage.ts';
44
48
  import { version } from './version.ts';
45
49
  import { print } from './output.ts';
@@ -74,6 +78,14 @@ export async function run(argv: readonly string[]): Promise<void> {
74
78
  return;
75
79
  }
76
80
 
81
+ // Before the switch, so no command can be reached having been handed a flag it
82
+ // does not read or missing one it needs. One call site rather than a check per
83
+ // case: the reported bug was a single command building its own options literal
84
+ // and dropping `--target` into it, which is exactly what a per-case check
85
+ // leaves room for.
86
+ assertKnownFlags(first, second, flags);
87
+ await requireSelection(first, second, flags);
88
+
77
89
  switch (first) {
78
90
  case 'connect':
79
91
  if (!second) throw new Error(`Usage: ${PROGRAM} connect <provider>`);
@@ -85,6 +97,7 @@ export async function run(argv: readonly string[]): Promise<void> {
85
97
  nonInteractive: flags['non-interactive'] === true,
86
98
  acceptBroadScopes: flags['accept-broad-scopes'] === true,
87
99
  ownClient: flags['own-client'] === true,
100
+ auth: text(flags, 'auth'),
88
101
  json,
89
102
  });
90
103
 
@@ -97,8 +110,17 @@ export async function run(argv: readonly string[]): Promise<void> {
97
110
  case 'profile':
98
111
  switch (second) {
99
112
  case 'add':
100
- if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile add <name> [--default]`);
101
- return profileAdd(rest[0], { default: flags['default'] === true, json });
113
+ if (!rest[0]) {
114
+ throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name> [--target <name>]`);
115
+ }
116
+ return profileAdd(rest[0], {
117
+ // Read from argv rather than from `flags`, because this is the one
118
+ // place a flag is a list: a profile declares every target it can run
119
+ // on, and the parser keeps only the last value of a repeated flag.
120
+ targets: all(argv, 'target'),
121
+ nonInteractive: flags['non-interactive'] === true,
122
+ json,
123
+ });
102
124
  case 'list':
103
125
  case undefined:
104
126
  return profileList({ json });
@@ -128,13 +150,35 @@ export async function run(argv: readonly string[]): Promise<void> {
128
150
  return targetList({ ...global, json, urls: flags['urls'] === true });
129
151
  case 'use':
130
152
  if (!rest[0]) throw new Error(`Usage: ${PROGRAM} target use <name>`);
131
- return targetUse(rest[0], global);
153
+ return targetUse(rest[0]);
132
154
  case 'show':
133
155
  return targetShow(rest[0], { ...global, json });
134
156
  default:
135
157
  throw new Error(`Unknown: ${PROGRAM} target ${second}`);
136
158
  }
137
159
 
160
+ case 'identity': {
161
+ // Both subcommands take the same two positionals, so the usage line is
162
+ // built once rather than written twice with one of them going stale.
163
+ const [kind, value] = rest;
164
+ const usage = (form: string): string =>
165
+ `Usage: ${PROGRAM} identity ${form}\n e.g. ${PROGRAM} identity add name "Your Name" --note "for open-source work"`;
166
+
167
+ switch (second) {
168
+ case 'add':
169
+ if (!kind || !value) throw new Error(usage('add <kind> <value> [--note text]'));
170
+ return identityAdd(kind, value, { ...global, note: text(flags, 'note'), json });
171
+ case 'list':
172
+ case undefined:
173
+ return identityList({ ...global, json });
174
+ case 'remove':
175
+ if (!kind || !value) throw new Error(usage('remove <kind> <value>'));
176
+ return identityRemove(kind, value, { ...global, json });
177
+ default:
178
+ throw new Error(`Unknown: ${PROGRAM} identity ${second}`);
179
+ }
180
+ }
181
+
138
182
  case 'policy':
139
183
  switch (second) {
140
184
  case 'list':
@@ -221,6 +265,19 @@ export async function run(argv: readonly string[]): Promise<void> {
221
265
  throw new Error(`Unknown: ${PROGRAM} skills ${second}`);
222
266
  }
223
267
 
268
+ // Beside `memory` and `skills` because it is the question they raise next:
269
+ // those two say what is stored, and this says where it is kept.
270
+ case 'knowledge':
271
+ switch (second) {
272
+ case 'show':
273
+ case undefined:
274
+ return knowledgeShow(knowledgeFlags(flags));
275
+ case 'use':
276
+ return knowledgeUse(rest[0], knowledgeFlags(flags));
277
+ default:
278
+ throw new Error(`Unknown: ${PROGRAM} knowledge ${second}`);
279
+ }
280
+
224
281
  case 'vault':
225
282
  switch (second) {
226
283
  case 'list':
@@ -252,6 +309,13 @@ export async function run(argv: readonly string[]): Promise<void> {
252
309
  case 'outputs':
253
310
  return outputs({ ...global, show, json });
254
311
 
312
+ // Beside `outputs` for the same reason `tools` is: it answers the next
313
+ // question a person has rather than the next one an agent has. `outputs`
314
+ // hands a harness a URL and a token; this opens the one page a person can
315
+ // read, and only a local endpoint serves it.
316
+ case 'dashboard':
317
+ return dashboard({ ...global, print: flags['print'] === true });
318
+
255
319
  // Beside `outputs` because it answers the next question. `outputs` says
256
320
  // where the endpoint is; this says what it would hand a client that asked
257
321
  // right now — which is the only way to tell a stale client from a wrong