beehaven 0.8.16

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,476 @@
1
+ /**
2
+ * mcp-install — put Clearly in front of the user's agents in one command.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * ⚠⚠ `mcp-config` PRINTED A JSON BLOCK AND CALLED IT DONE, AND THE NUMBERS SAY IT WAS NOT.
7
+ * Its own header records that it was written to fix "the documented flow ended one step short of
8
+ * working" — and then stopped one step short itself. Measured since: 14 tokens minted from the
9
+ * CLI and, on production, **2 MCP tool calls in the table's entire history**. Minting is not
10
+ * adoption. What stands between the two is a person opening `~/.claude/mcp.json` in an editor and
11
+ * merging JSON by hand, correctly, in a file that may already have servers in it.
12
+ *
13
+ * The Mac app has done this properly for a while (`mcp-installer.ts`). A CLI-only user — the
14
+ * headless box, the Linux dev, anyone who never installs the app — got the printout.
15
+ *
16
+ * ⚠ THE CLIENT DEFINITIONS AND THE MARKER ARE DELIBERATELY IDENTICAL TO THE APP'S. Same config
17
+ * paths, same `_managed-by` key, same `mcp-remote` bridge for Claude Desktop's stdio transport.
18
+ * Two installers writing one file with different ideas of what "ours" means is how a config gets
19
+ * clobbered or double-written. `verify-mcp-parity.mjs` reads both files and fails if they drift.
20
+ *
21
+ * ⚠ AND IT IS ADDITIVE, NEVER AUTHORITATIVE. It merges one key into `mcpServers`, backs the file
22
+ * up first when it finds an entry it did not write, and leaves every other server alone. A user's
23
+ * MCP config is theirs; we are a guest in it.
24
+ */
25
+ import fs from 'fs';
26
+ import os from 'os';
27
+ import path from 'path';
28
+ import { execFileSync } from 'child_process';
29
+ /** ⚠ Must match `mcp-installer.ts` in the Mac app — pinned by verify-mcp-parity. */
30
+ export const MAGIC_KEY = '_managed-by';
31
+ export const MAGIC_VAL = 'clearly-mac-app';
32
+ export function clientDefs() {
33
+ const home = os.homedir();
34
+ return [
35
+ {
36
+ id: 'claude-code',
37
+ label: 'Claude Code',
38
+ transport: 'http',
39
+ /**
40
+ * ⚠⚠ `~/.claude.json`, NOT `~/.claude/mcp.json` — THE OLD PATH WAS NEVER READ.
41
+ * Proven on Claude Code 2.1.251: `claude mcp add --scope user` writes `mcpServers` into
42
+ * `~/.claude.json`, and `claude mcp list` outside a project with its own `.mcp.json` showed
43
+ * no server at all for the old file. The token was valid the whole time; the client simply
44
+ * never looked. `apps/electron/electron/mcp-installer.ts` had the identical bug and
45
+ * `verify-mcp-parity` is what caught that these two doors had stopped agreeing.
46
+ *
47
+ * ⚠ READS STILL WORK GENERICALLY because the shape is the same — `mcpServers` at the top
48
+ * level — so only the WRITE and REMOVE need `kind: 'claude'`. Detection is unchanged.
49
+ */
50
+ configPath: path.join(home, '.claude.json'),
51
+ detect: path.join(home, '.claude'),
52
+ kind: 'claude',
53
+ },
54
+ {
55
+ id: 'claude-desktop',
56
+ label: 'Claude Desktop',
57
+ transport: 'stdio',
58
+ configPath: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
59
+ detect: path.join(home, 'Library', 'Application Support', 'Claude'),
60
+ appBundle: ['/Applications/Claude.app'],
61
+ kind: 'json',
62
+ },
63
+ {
64
+ id: 'cursor',
65
+ label: 'Cursor',
66
+ transport: 'http',
67
+ configPath: path.join(home, '.cursor', 'mcp.json'),
68
+ detect: path.join(home, '.cursor'),
69
+ appBundle: ['/Applications/Cursor.app'],
70
+ kind: 'json',
71
+ },
72
+ {
73
+ /**
74
+ * ⚠ STDIO VIA `mcp-remote`, NOT CODEX'S NATIVE HTTP TRANSPORT — and the reason is the token.
75
+ * `codex mcp add --url` supports only `--bearer-token-env-var`, i.e. the credential must be
76
+ * in the ENVIRONMENT when Codex runs. We cannot reliably put it there: a GUI launch, a fresh
77
+ * shell or a different terminal would each miss it, and the failure is a server that is
78
+ * configured and silently cannot authenticate. The bridge carries the header in the config
79
+ * instead — the same trade already accepted for Claude Desktop, and one mechanism rather
80
+ * than two.
81
+ */
82
+ id: 'codex',
83
+ label: 'Codex',
84
+ transport: 'stdio',
85
+ configPath: path.join(home, '.codex', 'config.toml'),
86
+ detect: path.join(home, '.codex'),
87
+ kind: 'codex',
88
+ },
89
+ ];
90
+ }
91
+ /**
92
+ * The server entry we write.
93
+ *
94
+ * ⚠ CLAUDE DESKTOP SPEAKS STDIO AND OUR SERVER SPEAKS HTTP, so that one client gets the standard
95
+ * `mcp-remote` bridge rather than a proxy of our own. The cost is honest: it needs `npx` on PATH
96
+ * at launch, and if it is missing the connection fails VISIBLY in Claude Desktop's MCP panel —
97
+ * strictly better than a silent nothing.
98
+ *
99
+ * ⚠ THE TOKEN IS A HEADER ARG, NEVER AN ENV VAR. The server authenticates with
100
+ * `Authorization: Bearer`; an env var is decoration nothing reads.
101
+ */
102
+ export function serverBlock(url, token, transport) {
103
+ const base = transport === 'http'
104
+ ? { type: 'http', url, headers: { Authorization: `Bearer ${token}` } }
105
+ : { command: 'npx', args: ['-y', 'mcp-remote', url, '--header', `Authorization: Bearer ${token}`] };
106
+ base[MAGIC_KEY] = MAGIC_VAL;
107
+ return base;
108
+ }
109
+ function loadJsonOrEmpty(p) {
110
+ if (!fs.existsSync(p))
111
+ return {};
112
+ try {
113
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ /**
120
+ * The `mcpServers` key an install writes.
121
+ *
122
+ * ⚠⚠ IT WAS THE LITERAL `clearly` EVERYWHERE, SO A SECOND WORKSPACE SILENTLY REPLACED THE FIRST.
123
+ * With tokens now bound to ONE workspace, that is not a cosmetic collision: installing while
124
+ * connected to a team overwrote the entry for home, and the agent lost a workspace it had been
125
+ * using — with the config still saying `clearly`, so nothing looked wrong.
126
+ *
127
+ * ⚠ THE DEFAULT KEEPS THE OLD NAME. Every entry already on disk is called `clearly`, and both the
128
+ * Mac app and `mcp remove` find it by that name; renaming the primary would orphan all of them.
129
+ */
130
+ export const PRIMARY_ENTRY = 'clearly';
131
+ /**
132
+ * Whether an installed bearer token is already bound to the active agent.
133
+ *
134
+ * An MCP token is opaque, so the config entry itself cannot answer this. The safe comparison is
135
+ * the non-secret prefix against `list-mcp-tokens`, whose rows carry the server-validated
136
+ * `agentId`. If the row is absent or unbound, return false and let the installer mint a new
137
+ * token for the active identity.
138
+ */
139
+ export function tokenBelongsToAgent(listResult, token, agentId) {
140
+ const prefix = String(token || '').slice(0, 12);
141
+ const wanted = String(agentId || '').trim();
142
+ if (!prefix || !wanted)
143
+ return false;
144
+ const rows = Array.isArray(listResult?.tokens)
145
+ ? listResult.tokens
146
+ : Array.isArray(listResult?.result?.tokens)
147
+ ? listResult.result.tokens
148
+ : [];
149
+ const row = rows.find((candidate) => String(candidate?.prefix || '') === prefix);
150
+ return !!row && !row.revokedAt && String(row.agentId || '').trim() === wanted;
151
+ }
152
+ /** Read one bearer credential from the named Codex MCP table without parsing unrelated TOML. */
153
+ export function codexTokenFromConfig(raw, wanted = PRIMARY_ENTRY) {
154
+ const escaped = wanted.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
155
+ const header = new RegExp(`^\\[mcp_servers\\.${escaped}\\][^\\r\\n]*(?:\\r?\\n(?!\\[)[^\\r\\n]*)*`, 'm');
156
+ const table = header.exec(raw)?.[0] ?? '';
157
+ const match = /Authorization: Bearer (ck_[A-Za-z0-9_-]+)/.exec(table);
158
+ return match?.[1] ?? null;
159
+ }
160
+ export function entryNameFor(address) {
161
+ if (!address || /^(home|workspace|me)$/i.test(address))
162
+ return PRIMARY_ENTRY;
163
+ const slug = address.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).toLowerCase();
164
+ return slug ? `${PRIMARY_ENTRY}-${slug}` : PRIMARY_ENTRY;
165
+ }
166
+ /**
167
+ * Is a `[mcp_servers.<entry>]` table present in Codex's config?
168
+ *
169
+ * ⚠ A REGEX FOR A TABLE HEADER, NOT A TOML PARSE. Detecting presence needs one line; parsing the
170
+ * whole document to answer it would mean shipping a TOML parser to read a file we deliberately do
171
+ * not write ourselves. Reading is allowed to be cheap because a wrong answer costs a redundant
172
+ * `codex mcp add`, which is idempotent.
173
+ */
174
+ function codexHasEntry(configPath, entry) {
175
+ try {
176
+ const raw = fs.readFileSync(configPath, 'utf8');
177
+ return new RegExp(`^\\[mcp_servers\\.${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm').test(raw);
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
183
+ /** Every `[mcp_servers.*]` table Codex knows about that looks like ours. */
184
+ /**
185
+ * ⚠⚠ CLAUDE CODE HAS NO MARKER EITHER, FOR THE SAME REASON CODEX DOES NOT — and this was found by
186
+ * running the shipped binary, not by reading it. Its entries are written by `claude mcp add`,
187
+ * whose flags cover `--transport`, `--header` and `--env` and nothing else, so there is no way to
188
+ * stamp `_managed-by`. An ownership test that looks for the marker therefore finds NOTHING: the
189
+ * status line said "detected, not configured" for a server that was configured and connected, and
190
+ * `removeClearlyEntry` would have refused with "no clearly entry" on a live install.
191
+ *
192
+ * ⚠ SO OWNERSHIP IS BY NAME, and the consequence is stated rather than hidden — exactly as it is
193
+ * for Codex: a hand-written `clearly` server would be overwritten by an install and removed by an
194
+ * uninstall. The alternative is hand-editing `~/.claude.json`, which holds the user's entire
195
+ * Claude Code state and is the thing we are avoiding.
196
+ */
197
+ function claudeEntries(configPath) {
198
+ try {
199
+ const cfg = loadJsonOrEmpty(configPath);
200
+ return Object.keys(cfg?.mcpServers ?? {})
201
+ .filter((n) => n === PRIMARY_ENTRY || n.startsWith(`${PRIMARY_ENTRY}-`));
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
207
+ function codexEntries(configPath) {
208
+ try {
209
+ const raw = fs.readFileSync(configPath, 'utf8');
210
+ return [...raw.matchAll(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]/gm)]
211
+ .map((m) => m[1])
212
+ .filter((n) => n === PRIMARY_ENTRY || n.startsWith(`${PRIMARY_ENTRY}-`));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ }
218
+ /**
219
+ * ⚠⚠ CODEX HAS NO MARKER, AND CANNOT HAVE ONE. Our JSON clients carry `_managed-by` so we never
220
+ * touch an entry we did not write; TOML tables here hold only the fields Codex's own schema
221
+ * defines, and adding a stray key would either be dropped on the next rewrite or rejected.
222
+ *
223
+ * So ownership is inferred from the NAME instead — `clearly` and `clearly-*`. That is weaker, and
224
+ * the consequence is stated rather than hidden: a hand-written `[mcp_servers.clearly]` would be
225
+ * overwritten by an install and removed by an uninstall. It is the same name our own installer
226
+ * would have chosen, so the collision is unlikely; it is not impossible.
227
+ */
228
+ /** Run a `claude mcp …` subcommand. Same contract as `codexRun`: the tool owns its format. */
229
+ function claudeRun(args) {
230
+ try {
231
+ execFileSync('claude', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 });
232
+ return { ok: true };
233
+ }
234
+ catch (e) {
235
+ return { ok: false, reason: (e?.stderr?.toString?.() || e?.message || String(e)).trim().slice(0, 200) };
236
+ }
237
+ }
238
+ function codexRun(args) {
239
+ try {
240
+ execFileSync('codex', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 });
241
+ return { ok: true };
242
+ }
243
+ catch (e) {
244
+ return { ok: false, reason: (e?.stderr?.toString?.() || e?.message || String(e)).trim().slice(0, 200) };
245
+ }
246
+ }
247
+ export function writeClientConfig(def, url, token, entry = PRIMARY_ENTRY) {
248
+ const { configPath, transport } = def;
249
+ let backedUpTo;
250
+ if (def.kind === 'claude') {
251
+ /**
252
+ * ⚠⚠ ITS OWN CLI, NEVER A HAND-EDIT. `~/.claude.json` holds Claude Code's ENTIRE user state —
253
+ * every project's history, onboarding flags, permissions. The same reasoning this file already
254
+ * applies to Codex's TOML applies with more force to a file that large: a writer that dropped
255
+ * a key in a config we do not own would be a destructive bug.
256
+ *
257
+ * ⚠ `add` REFUSES AN EXISTING NAME (unlike `codex mcp add`, which overwrites), so a rotated
258
+ * token needs the old entry removed first — without it every rotation after the first leaves
259
+ * the STALE credential in place and still reports success. The remove is best-effort: on a
260
+ * first install there is nothing to remove and it exits non-zero, which is not a failure.
261
+ */
262
+ claudeRun(['mcp', 'remove', '--scope', 'user', entry]);
263
+ const r = claudeRun(['mcp', 'add', '--scope', 'user', '--transport', 'http', entry, url,
264
+ '--header', `Authorization: Bearer ${token}`]);
265
+ return r.ok ? { changed: true } : { changed: false, reason: r.reason };
266
+ }
267
+ if (def.kind === 'codex') {
268
+ /**
269
+ * ⚠ `codex mcp add` IS IDEMPOTENT — adding the same name twice overwrites — so there is no
270
+ * read-modify-write for us to get wrong, and no need to remove first.
271
+ */
272
+ const r = codexRun(['mcp', 'add', entry, '--', 'npx', '-y', 'mcp-remote', url,
273
+ '--header', `Authorization: Bearer ${token}`]);
274
+ return r.ok ? { changed: true } : { changed: false, reason: r.reason };
275
+ }
276
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
277
+ const existing = loadJsonOrEmpty(configPath);
278
+ /**
279
+ * ⚠ AN UNPARSEABLE CONFIG IS BACKED UP, NEVER SILENTLY REPLACED. `loadJsonOrEmpty` returns `{}`
280
+ * for a file it could not parse, and writing over that would discard every server the user had.
281
+ * A file that exists but reads as empty is the signature of exactly that case.
282
+ */
283
+ if (fs.existsSync(configPath) && Object.keys(existing).length === 0) {
284
+ try {
285
+ if (fs.readFileSync(configPath, 'utf8').trim()) {
286
+ backedUpTo = `${configPath}.backup-${Date.now()}`;
287
+ fs.copyFileSync(configPath, backedUpTo);
288
+ }
289
+ }
290
+ catch { /* best-effort */ }
291
+ }
292
+ const next = { ...existing };
293
+ if (!next.mcpServers || typeof next.mcpServers !== 'object')
294
+ next.mcpServers = {};
295
+ const desired = serverBlock(url, token, transport);
296
+ const current = next.mcpServers[entry];
297
+ const currentIsOurs = current && current[MAGIC_KEY] === MAGIC_VAL;
298
+ // A `clearly` entry we did not write is hand-tuned until proven otherwise.
299
+ if (current && !currentIsOurs && !backedUpTo) {
300
+ backedUpTo = `${configPath}.backup-${Date.now()}`;
301
+ try {
302
+ fs.copyFileSync(configPath, backedUpTo);
303
+ }
304
+ catch { /* best-effort */ }
305
+ }
306
+ next.mcpServers[entry] = desired;
307
+ try {
308
+ fs.writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
309
+ /**
310
+ * ⚠ `mode` ON `writeFileSync` APPLIES ONLY WHEN THE FILE IS CREATED. An existing config keeps
311
+ * whatever permissions it had, and this file now holds a bearer token.
312
+ */
313
+ try {
314
+ fs.chmodSync(configPath, 0o600);
315
+ }
316
+ catch { /* best-effort */ }
317
+ }
318
+ catch (e) {
319
+ return { changed: false, reason: `could not write ${configPath}: ${e?.message ?? e}` };
320
+ }
321
+ return { changed: true, backedUpTo };
322
+ }
323
+ /**
324
+ * The token already installed in a client config we own, if any.
325
+ *
326
+ * ⚠⚠ THE CLI MINTED A FRESH TOKEN ON EVERY RUN AND NEVER REVOKED THE LAST ONE. Three runs today
327
+ * left three live `ck_mcp_` credentials, each valid for a year, each unreachable by the user
328
+ * because the config only holds the newest. Re-running an installer is the most ordinary thing a
329
+ * person does — after a reinstall, after switching machines, after a failed attempt — and it was
330
+ * quietly issuing a new bearer token every time.
331
+ *
332
+ * ⚠ THE MAC APP ALREADY GOT THIS RIGHT and the divergence was the bug: it caches the token and
333
+ * mints only when absent or past `TOKEN_ROTATE_DAYS`. The CLI has no separate store, but it does
334
+ * not need one — the client config IS the store, and it is the only place the token was ever kept.
335
+ *
336
+ * ⚠ ONLY FROM AN ENTRY CARRYING OUR MARKER. A `clearly` server somebody hand-wrote holds a token
337
+ * we did not mint and may not revoke or reuse.
338
+ */
339
+ export function existingToken(wanted) {
340
+ for (const d of clientDefs()) {
341
+ try {
342
+ if (!fs.existsSync(d.configPath))
343
+ continue;
344
+ if (d.kind === 'codex') {
345
+ /** The header lives inside the `args` array Codex wrote. */
346
+ const raw = fs.readFileSync(d.configPath, 'utf8');
347
+ const token = codexTokenFromConfig(raw, wanted ?? PRIMARY_ENTRY);
348
+ if (token)
349
+ return token;
350
+ continue;
351
+ }
352
+ const cfg = loadJsonOrEmpty(d.configPath);
353
+ const entry = cfg?.mcpServers?.[wanted ?? PRIMARY_ENTRY];
354
+ if (!entry || entry[MAGIC_KEY] !== MAGIC_VAL)
355
+ continue;
356
+ const header = entry?.headers?.Authorization
357
+ ?? (Array.isArray(entry?.args) ? String(entry.args.find((a) => /^Authorization: /.test(a)) ?? '') : '');
358
+ const m = /Bearer\s+(ck_[A-Za-z0-9_]+)/.exec(header);
359
+ if (m)
360
+ return m[1];
361
+ }
362
+ catch { /* unreadable — treat as absent */ }
363
+ }
364
+ return null;
365
+ }
366
+ /** `ck_mcp_0e40e` — the 12-char prefix `revoke-mcp-token` matches on. */
367
+ export function tokenPrefix(token) {
368
+ return token.slice(0, 12);
369
+ }
370
+ /**
371
+ * Is this client actually installed?
372
+ *
373
+ * ⚠⚠ THE APP AND THE CLI ASKED DIFFERENT QUESTIONS AND GOT DIFFERENT ANSWERS. The Mac app checks
374
+ * for the APPLICATION (`/Applications/Cursor.app`, `which claude`); this checked only for the
375
+ * CONFIG DIRECTORY (`~/.cursor`). On one machine that made the app skip Cursor while the CLI
376
+ * configured it — a config file written for software that is not installed.
377
+ *
378
+ * ⚠ THE APP'S SIGNAL IS STRONGER AND IS USED WHERE IT EXISTS. A leftover `~/.cursor` outlives an
379
+ * uninstall, so the directory proves only that the client was here once.
380
+ *
381
+ * ⚠ AND THE FALLBACK IS NOT A REGRESSION, IT IS THE ONLY PORTABLE SIGNAL. `/Applications` means
382
+ * nothing on Linux or in a container, which is exactly where the CLI runs and the app does not.
383
+ * Off darwin, the config directory is the best evidence available.
384
+ */
385
+ function clientPresent(d) {
386
+ /**
387
+ * ⚠ CODEX IS A CLI, SO THE TEST IS THE BINARY — the same signal the app uses for Claude Code, and
388
+ * the one that matters: writing to `~/.codex/config.toml` is only useful if `codex` can run.
389
+ * The directory alone survives an uninstall.
390
+ */
391
+ if (d.kind === 'codex') {
392
+ try {
393
+ execFileSync('/usr/bin/which', ['codex'], { stdio: ['ignore', 'pipe', 'ignore'] });
394
+ return true;
395
+ }
396
+ catch {
397
+ return false;
398
+ }
399
+ }
400
+ if (process.platform === 'darwin' && d.appBundle) {
401
+ return d.appBundle.some((b) => fs.existsSync(b) || fs.existsSync(path.join(os.homedir(), b.replace(/^\//, ''))));
402
+ }
403
+ return fs.existsSync(d.detect);
404
+ }
405
+ export function detectClients() {
406
+ return clientDefs().map((d) => {
407
+ const detected = clientPresent(d);
408
+ /**
409
+ * ⚠ COUNT EVERY ENTRY WE OWN, not just the primary. Once a workspace can have its own entry,
410
+ * reporting only `clearly` would call a machine "not configured" while three of our own
411
+ * servers sat in its config.
412
+ */
413
+ let ours = [];
414
+ if (d.kind === 'codex') {
415
+ ours = codexEntries(d.configPath);
416
+ return { ...d, detected, installed: ours.length > 0, entries: ours };
417
+ }
418
+ if (d.kind === 'claude') {
419
+ ours = claudeEntries(d.configPath);
420
+ return { ...d, detected, installed: ours.length > 0, entries: ours };
421
+ }
422
+ if (fs.existsSync(d.configPath)) {
423
+ const c = loadJsonOrEmpty(d.configPath);
424
+ ours = Object.entries(c?.mcpServers ?? {})
425
+ .filter(([, v]) => v?.[MAGIC_KEY] === MAGIC_VAL)
426
+ .map(([k]) => k);
427
+ }
428
+ return { ...d, detected, installed: ours.length > 0, entries: ours };
429
+ });
430
+ }
431
+ /** Remove only an entry we wrote. A hand-authored `clearly` server is left alone. */
432
+ export function removeClientConfig(def) {
433
+ if (def.kind === 'claude') {
434
+ const mine = claudeEntries(def.configPath);
435
+ if (!mine.length)
436
+ return { removed: false, reason: 'no clearly entry' };
437
+ const bad = [];
438
+ for (const n of mine) {
439
+ const r = claudeRun(['mcp', 'remove', '--scope', 'user', n]);
440
+ if (!r.ok)
441
+ bad.push(`${n}: ${r.reason}`);
442
+ }
443
+ return bad.length ? { removed: false, reason: bad.join('; ') } : { removed: true };
444
+ }
445
+ if (def.kind === 'codex') {
446
+ const names = codexEntries(def.configPath);
447
+ if (!names.length)
448
+ return { removed: false, reason: 'no clearly entry' };
449
+ const failures = [];
450
+ for (const n of names) {
451
+ const r = codexRun(['mcp', 'remove', n]);
452
+ if (!r.ok)
453
+ failures.push(`${n}: ${r.reason}`);
454
+ }
455
+ return failures.length ? { removed: false, reason: failures.join('; ') } : { removed: true };
456
+ }
457
+ if (!fs.existsSync(def.configPath))
458
+ return { removed: false, reason: 'no config' };
459
+ const cfg = loadJsonOrEmpty(def.configPath);
460
+ const owned = Object.entries(cfg?.mcpServers ?? {})
461
+ .filter(([, v]) => v?.[MAGIC_KEY] === MAGIC_VAL)
462
+ .map(([k]) => k);
463
+ if (!owned.length) {
464
+ const anyClearly = cfg?.mcpServers?.[PRIMARY_ENTRY];
465
+ return { removed: false, reason: anyClearly ? 'entry is not ours — left alone' : 'no clearly entry' };
466
+ }
467
+ for (const k of owned)
468
+ delete cfg.mcpServers[k];
469
+ try {
470
+ fs.writeFileSync(def.configPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
471
+ return { removed: true };
472
+ }
473
+ catch (e) {
474
+ return { removed: false, reason: e?.message ?? String(e) };
475
+ }
476
+ }