@clearlysh/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,633 @@
1
+ /**
2
+ * claude-credential-store — daemon-side persistent storage for the
3
+ * user's Claude API key / plan token, written by the
4
+ * `canvas-credential-store` relay handler.
5
+ *
6
+ * Storage priority:
7
+ * 1. macOS Keychain via `security(1)` CLI — preferred. Stored under
8
+ * service "clearly-claude-credential" + account "default", which
9
+ * keeps it discoverable via Keychain Access.app and survives
10
+ * OS reinstalls / disk migrations cleanly.
11
+ * 2. ~/.beehaven/claude-credentials JSON file with mode 600 —
12
+ * fallback for non-macOS hosts OR when security(1) is unavailable
13
+ * (e.g. inside containerised dev environments).
14
+ *
15
+ * The Agent SDK reads from one of these on each query() — see
16
+ * daemon-omni.ts's setup of `buildSdkOptions` (a future change will
17
+ * teach it to consult this store before falling back to
18
+ * ANTHROPIC_API_KEY env). For now, this module writes the value to BOTH
19
+ * the keychain AND the process env (`ANTHROPIC_API_KEY`) so the SDK
20
+ * picks it up on the very next turn without further changes.
21
+ *
22
+ * ── WHAT THIS STORE DOES NOT PERSIST ────────────────────────────────
23
+ *
24
+ * **Masked display strings.** A mask is a pure function of the secret,
25
+ * so it is DERIVED on every read, never written. Persisting it bought
26
+ * nothing and cost twice:
27
+ *
28
+ * 1. Drift. The keychain path always recomputed the mask and ignored
29
+ * whatever was stored, while the file fallback trusted the stored
30
+ * copy — two sources of truth for one string, guaranteed to
31
+ * disagree the first time the format changed.
32
+ * 2. A real bug. The `plan` record was ONLY a mask, and that mask
33
+ * carried a typographic ellipsis (U+2026). `security(1)` hex-encodes
34
+ * any non-ASCII password on read, so `JSON.parse` threw and
35
+ * readClaudeCredential() returned null for every plan credential
36
+ * ever written on macOS — the lane looked disconnected after any
37
+ * daemon restart. A presentation concern reached into the storage
38
+ * layer and broke it.
39
+ *
40
+ * **Plan connection state.** `credType:'plan'` now stores NOTHING at
41
+ * all. We do not own that fact — `~/.claude/.credentials.json` does,
42
+ * written by Anthropic's own CLI. A stored marker was a cache of
43
+ * someone else's state with no invalidation: it survived `claude
44
+ * logout` and token expiry and kept reporting "connected". Plan status
45
+ * is now read live from that file on every call, so it cannot go stale.
46
+ *
47
+ * ⚠ Consequence worth knowing: "Disconnect" cannot revoke plan auth,
48
+ * because there is nothing of ours to delete. It clears any stored key
49
+ * and the env; revoking the subscription itself is `claude logout`.
50
+ */
51
+ import { spawn } from 'node:child_process';
52
+ import { promises as fs, watch as fsWatch, existsSync } from 'node:fs';
53
+ import { homedir, platform, arch } from 'node:os';
54
+ import { join, dirname } from 'node:path';
55
+ import { fileURLToPath } from 'node:url';
56
+ const SERVICE = 'clearly-claude-credential';
57
+ const ACCOUNT = 'default';
58
+ const FALLBACK_DIR = join(homedir(), '.beehaven');
59
+ const FALLBACK_FILE = join(FALLBACK_DIR, 'claude-credentials');
60
+ /** Mask an API key for display. Derived, never stored. ASCII only —
61
+ * a non-ASCII mask is what made `security(1)` hex-encode the record. */
62
+ function maskKey(credType, key) {
63
+ // OpenRouter keeps more prefix ("sk-or-v1…") since users often have
64
+ // multiple OR keys and the prefix helps disambiguate.
65
+ const head = credType === 'openrouter-key' ? 10 : 8;
66
+ return `${key.slice(0, head)}...${key.slice(-4)}`;
67
+ }
68
+ /**
69
+ * Read the LIVE plan auth from Claude Code's own credentials file.
70
+ *
71
+ * This is the source of truth for "is a Claude subscription authorized
72
+ * on this machine" — we never copy it, cache it, or write it anywhere.
73
+ * Probes the canonical `.credentials.json` then the legacy
74
+ * `credentials.json` (no dot) that older Claude Code releases wrote.
75
+ *
76
+ * Returns null when no subscription auth is present.
77
+ */
78
+ async function readLivePlanAuth() {
79
+ for (const path of [CLAUDE_CREDENTIALS_FILE, CLAUDE_CREDENTIALS_LEGACY_FILE]) {
80
+ try {
81
+ const parsed = JSON.parse(await fs.readFile(path, 'utf8'));
82
+ const token = parsed?.claudeAiOauth?.accessToken;
83
+ if (typeof token === 'string' && token) {
84
+ return {
85
+ maskedKey: `Pro/Max plan (...${token.slice(-4)})`,
86
+ expiresAt: typeof parsed.claudeAiOauth.expiresAt === 'number'
87
+ ? parsed.claudeAiOauth.expiresAt
88
+ : undefined,
89
+ };
90
+ }
91
+ }
92
+ catch {
93
+ // Missing / mid-atomic-write / malformed — try the next location.
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ /** OpenRouter's Anthropic-compatible endpoint. The Anthropic Agent SDK
99
+ * reads `ANTHROPIC_BASE_URL` to override its default api.anthropic.com
100
+ * target — pointing it here routes the (otherwise identical) Anthropic-
101
+ * protocol traffic through OpenRouter, which forwards to the underlying
102
+ * Anthropic model (or any compatible Claude SKU) and bills against the
103
+ * caller's OpenRouter account instead of an Anthropic account. */
104
+ const OPENROUTER_ANTHROPIC_BASE_URL = 'https://openrouter.ai/api/v1';
105
+ export async function storeClaudeCredential(input) {
106
+ const { credType, apiKey, planTokenHint } = input;
107
+ // ── plan: persist NOTHING ─────────────────────────────────────────
108
+ // Selecting the plan lane is not a write, it's a switch. Anthropic's
109
+ // CLI owns the token; all we do is get our key-based env out of the
110
+ // way so the `claude` auth chain falls through to subscription OAuth
111
+ // (ANTHROPIC_API_KEY outranks it), and drop any stored key so the old
112
+ // one can't win again on the next daemon restart.
113
+ //
114
+ // The mask we return is read LIVE from the credentials file, so the
115
+ // UI shows the token actually in force rather than a remembered one.
116
+ if (credType === 'plan') {
117
+ await deleteClaudeCredential(); // removes stored key + clears both env vars
118
+ const live = await readLivePlanAuth();
119
+ return {
120
+ storage: 'none',
121
+ maskedKey: live?.maskedKey ??
122
+ (planTokenHint ? `Pro/Max plan (...${planTokenHint})` : 'Pro/Max plan'),
123
+ };
124
+ }
125
+ if (!apiKey)
126
+ throw new Error(`apiKey required for ${credType} credType`);
127
+ const secretValue = apiKey;
128
+ // Derived for the return value only — never written. See the header.
129
+ const masked = maskKey(credType, apiKey);
130
+ // ── Try macOS Keychain first ──────────────────────────────────────
131
+ if (platform() === 'darwin') {
132
+ try {
133
+ // Delete any prior entry — security add-generic-password errors
134
+ // out if a duplicate (service, account) pair already exists. Idempotency
135
+ // via delete-then-add is simpler than detecting the prior state.
136
+ await runSecurity(['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]).catch(() => { });
137
+ await runSecurity([
138
+ 'add-generic-password',
139
+ '-s', SERVICE,
140
+ '-a', ACCOUNT,
141
+ '-w', secretValue,
142
+ '-U', // update if exists (belt + suspenders alongside the delete above)
143
+ // Description visible in Keychain Access — helps users understand
144
+ // what the entry is when they audit their keychain.
145
+ '-D', 'Clearly — Claude API key / plan auth',
146
+ '-j', 'Stored by the Hive Mac daemon for use by the Claude Agent SDK.',
147
+ ]);
148
+ // Also surface the secret to the daemon's process env so the
149
+ // CURRENT-PROCESS Agent SDK picks it up immediately. The next
150
+ // daemon restart will re-read from keychain (a future
151
+ // daemon-startup hook).
152
+ //
153
+ // For openrouter-key we ALSO set ANTHROPIC_BASE_URL so the
154
+ // Anthropic Agent SDK routes through OpenRouter's compat
155
+ // endpoint instead of api.anthropic.com; for a direct Anthropic
156
+ // key we clear it, so switching lanes can't strand a stale
157
+ // OpenRouter base URL that silently bills the wrong account.
158
+ process.env.ANTHROPIC_API_KEY = secretValue;
159
+ if (credType === 'openrouter-key') {
160
+ process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
161
+ }
162
+ else {
163
+ delete process.env.ANTHROPIC_BASE_URL;
164
+ }
165
+ return { storage: 'keychain', maskedKey: masked };
166
+ }
167
+ catch (err) {
168
+ // Fall through to filesystem fallback. Don't bubble — keychain
169
+ // unavailability is recoverable; we just lose the lifecycle bind.
170
+ // eslint-disable-next-line no-console
171
+ console.warn(`[claude-credential-store] keychain write failed, falling back to file: ${err?.message ?? err}`);
172
+ }
173
+ }
174
+ // ── Filesystem fallback ───────────────────────────────────────────
175
+ // Mode 600 — owner-only read/write. Survives daemon restarts; a
176
+ // future enhancement could encrypt with a machine-id-derived key.
177
+ await fs.mkdir(FALLBACK_DIR, { recursive: true });
178
+ // No maskedKey field — it is derived on read, same as the keychain
179
+ // path. Storing it here is what let the two paths drift.
180
+ const payload = {
181
+ credType,
182
+ secret: secretValue,
183
+ storedAt: Date.now(),
184
+ };
185
+ await fs.writeFile(FALLBACK_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
186
+ // Same env handling as the keychain path above.
187
+ process.env.ANTHROPIC_API_KEY = secretValue;
188
+ if (credType === 'openrouter-key') {
189
+ process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
190
+ }
191
+ else {
192
+ delete process.env.ANTHROPIC_BASE_URL;
193
+ }
194
+ return { storage: 'file', maskedKey: masked };
195
+ }
196
+ /**
197
+ * readClaudeCredential — fetch the stored value back. Used by the
198
+ * daemon's startup hook to re-hydrate process.env on relaunch, and
199
+ * by an introspection RPC the settings panel uses to verify the
200
+ * keychain still has the entry.
201
+ *
202
+ * Returns null when nothing is stored.
203
+ */
204
+ export async function readClaudeCredential() {
205
+ // macOS keychain first.
206
+ if (platform() === 'darwin') {
207
+ try {
208
+ const stdout = await runSecurity(['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w']);
209
+ const secret = stdout.trim();
210
+ if (!secret)
211
+ return null;
212
+ if (secret.startsWith('sk-or-')) {
213
+ return { credType: 'openrouter-key', secret, maskedKey: maskKey('openrouter-key', secret) };
214
+ }
215
+ if (secret.startsWith('sk-ant-')) {
216
+ return { credType: 'api-key', secret, maskedKey: maskKey('api-key', secret) };
217
+ }
218
+ // Anything else is a LEGACY plan marker — either readable JSON or,
219
+ // far more often, the hex `security(1)` emits for the non-ASCII
220
+ // ellipsis it contained (which is why these never read back). It
221
+ // holds no secret and no fact we can't get live, so drop it and
222
+ // let the live check below answer.
223
+ await deleteClaudeCredential();
224
+ }
225
+ catch {
226
+ // keychain miss — fall through to file fallback
227
+ }
228
+ }
229
+ // Filesystem fallback. Mask is DERIVED here too — reading a stored
230
+ // one is what let this path disagree with the keychain path.
231
+ try {
232
+ const raw = await fs.readFile(FALLBACK_FILE, 'utf8');
233
+ const parsed = JSON.parse(raw);
234
+ const t = parsed?.credType;
235
+ if (parsed?.secret && (t === 'api-key' || t === 'openrouter-key')) {
236
+ return { credType: t, secret: parsed.secret, maskedKey: maskKey(t, parsed.secret) };
237
+ }
238
+ // Legacy plan marker in the file fallback — same treatment as the
239
+ // keychain one above.
240
+ if (t === 'plan')
241
+ await deleteClaudeCredential();
242
+ }
243
+ catch {
244
+ // file not present — no credential stored
245
+ }
246
+ // ── Nothing of ours is stored. Is a Claude subscription authorized
247
+ // on this machine? Read it live; never cached, so it cannot be
248
+ // stale after `claude logout` or a token expiry.
249
+ const live = await readLivePlanAuth();
250
+ if (live)
251
+ return { credType: 'plan', secret: '', maskedKey: live.maskedKey };
252
+ return null;
253
+ }
254
+ /**
255
+ * deleteClaudeCredential — remove from both keychain + fallback. Used
256
+ * by the `canvas-credential-disconnect` IPC path (future) so a
257
+ * worker-side disconnect propagates to the daemon's stored credential.
258
+ * Today the worker just clears its row; calling this from the worker
259
+ * side is a follow-up.
260
+ */
261
+ export async function deleteClaudeCredential() {
262
+ if (platform() === 'darwin') {
263
+ await runSecurity(['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]).catch(() => { });
264
+ }
265
+ try {
266
+ await fs.unlink(FALLBACK_FILE);
267
+ }
268
+ catch { /* idempotent */ }
269
+ // Clear ALL env vars the SDK could pick up — both the Anthropic
270
+ // key and any OpenRouter base-URL override. Leaving BASE_URL set
271
+ // after a disconnect would route a subsequent `claude login` Keychain
272
+ // token through OpenRouter (still valid, but bills against the wrong
273
+ // account silently).
274
+ delete process.env.ANTHROPIC_API_KEY;
275
+ delete process.env.ANTHROPIC_BASE_URL;
276
+ }
277
+ // ──────────────────────────────────────────────────────────────────────
278
+ // Plan-SDK OAuth — `claude setup-token` driver + credentials watcher
279
+ // ──────────────────────────────────────────────────────────────────────
280
+ //
281
+ // The Anthropic Agent SDK plus the Claude Code CLI handle OAuth against
282
+ // claude.ai natively. From our daemon we just need to:
283
+ // 1. Find the `claude` binary (bundled with the Agent SDK as a
284
+ // platform-native optional dep, or installed at ~/.local/bin)
285
+ // 2. Spawn `claude setup-token` — it opens the user's browser, runs
286
+ // the OAuth flow, and writes ~/.claude/.credentials.json on success
287
+ // 3. Watch ~/.claude/.credentials.json for the post-auth state and
288
+ // notify the worker via canvas-credential-detected
289
+ //
290
+ // Per the May 13, 2026 Anthropic announcement + June 15, 2026 policy
291
+ // change, third-party Agent SDK apps may now use this flow — usage
292
+ // draws against the user's monthly Agent SDK credit pool ($20 Pro /
293
+ // $100 Max 5x / $200 Max 20x) which is separate from interactive
294
+ // limits. The OAuth flow itself is handled by Anthropic's CLI; we
295
+ // just kick it off + listen for completion.
296
+ const CLAUDE_CREDENTIALS_FILE = join(homedir(), '.claude', '.credentials.json');
297
+ // Older Claude Code releases (and some `claude login` flows) wrote the
298
+ // auth blob to ~/.claude/credentials.json — no dot prefix. The shape is
299
+ // identical (`{ claudeAiOauth: { accessToken, refreshToken, expiresAt,
300
+ // tokenType } }`); only the filename differs. We probe both locations so
301
+ // users coming from the older path don't need to re-run `claude setup-
302
+ // token` for the daemon to recognize their existing auth.
303
+ const CLAUDE_CREDENTIALS_LEGACY_FILE = join(homedir(), '.claude', 'credentials.json');
304
+ /**
305
+ * Locate the `claude` CLI binary the daemon can spawn. Resolution order:
306
+ * 1. Platform-native bundled binary from
307
+ * `@anthropic-ai/claude-agent-sdk-{platform}-{arch}` (the SDK's optional native package)
308
+ * 2. `node_modules/.bin/claude` — if a sibling package installed one
309
+ * 3. System install at `~/.local/bin/claude` (Anthropic's installer default)
310
+ * 4. PATH lookup via `which claude`
311
+ *
312
+ * ⚠⚠ CANDIDATE 1 CANNOT RESOLVE TODAY, AND THIS DOCSTRING USED TO CLAIM THE OPPOSITE. It ended
313
+ * *"which will rarely happen since we bundle the SDK platform packages"* — we do not. The SDK
314
+ * declares eight optional platform packages and **none is installed anywhere in this monorepo**,
315
+ * so nothing is bundled and no electron-builder config copies one. The measured reason is size:
316
+ * the Claude Code runtime is ~200 MB per version against a 561 MB app, ~36% for one architecture.
317
+ *
318
+ * ⚠⚠ SO IN PRACTICE THIS RESOLVES AT STEP 3 OR 4 — the user's OWN Claude Code install. That is the
319
+ * BYOK model working as intended, but it means the capability is absent on a machine that has
320
+ * never installed Claude Code, and **the caller must say so plainly** rather than reporting a
321
+ * generic failure. A false claim in a docstring is worse than no docstring: it is why nobody
322
+ * checked.
323
+ *
324
+ * Returns null when nothing is found.
325
+ */
326
+ export async function findClaudeBinary() {
327
+ const candidates = [];
328
+ // 1. SDK bundled platform-native binary. Resolve relative to this
329
+ // module's location so it works regardless of cwd.
330
+ try {
331
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
332
+ const sdkRoot = join(moduleDir, '..', 'node_modules', '@anthropic-ai');
333
+ const nativePkg = `claude-agent-sdk-${platform()}-${arch()}`;
334
+ candidates.push(join(sdkRoot, nativePkg, 'claude'));
335
+ candidates.push(join(sdkRoot, nativePkg, 'bin', 'claude'));
336
+ // Also search up the tree — the Mac app's electron-builder unpacks
337
+ // node_modules into Resources/app.asar.unpacked
338
+ for (let depth = 2; depth <= 5; depth++) {
339
+ candidates.push(join(moduleDir, ...Array(depth).fill('..'), 'node_modules', '@anthropic-ai', nativePkg, 'claude'));
340
+ }
341
+ }
342
+ catch { /* file URL conversion failure — skip */ }
343
+ /**
344
+ * 2. A sibling `node_modules/.bin/claude`, resolved RELATIVE TO THIS MODULE.
345
+ *
346
+ * ⚠⚠ THIS WAS A HARDCODED ABSOLUTE PATH INTO ONE DEVELOPER'S HOME DIRECTORY —
347
+ * `~/projects/clearly/beehaven_sdk/packages/cli/node_modules/.bin/claude` — shipped inside a
348
+ * signed app. On that one machine it made the agent surface work for a reason no customer
349
+ * shares; on every other machine it is a guaranteed miss that costs a stat() and hides the real
350
+ * answer ("Claude Code is not installed") behind a path nobody recognises.
351
+ *
352
+ * Resolved from `import.meta.url` it means what it was trying to mean, in a checkout AND in a
353
+ * bundle, with nobody's username in it.
354
+ */
355
+ try {
356
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
357
+ for (let depth = 1; depth <= 4; depth++) {
358
+ candidates.push(join(moduleDir, ...Array(depth).fill('..'), 'node_modules', '.bin', 'claude'));
359
+ }
360
+ }
361
+ catch { /* file URL conversion failure — skip */ }
362
+ // 3. User-level install (Anthropic's installer default)
363
+ candidates.push(join(homedir(), '.local', 'bin', 'claude'));
364
+ candidates.push(join(homedir(), '.local', 'share', 'claude', 'bin', 'claude'));
365
+ for (const p of candidates) {
366
+ if (existsSync(p))
367
+ return p;
368
+ }
369
+ // 4. Last-resort PATH lookup
370
+ return new Promise((resolve) => {
371
+ const child = spawn('which', ['claude'], { stdio: ['ignore', 'pipe', 'ignore'] });
372
+ let out = '';
373
+ child.stdout.on('data', (d) => { out += d.toString('utf8'); });
374
+ child.on('close', (code) => {
375
+ const path = out.trim();
376
+ resolve(code === 0 && path ? path : null);
377
+ });
378
+ child.on('error', () => resolve(null));
379
+ });
380
+ }
381
+ /**
382
+ * Spawn `claude setup-token` to kick off the plan-SDK OAuth flow.
383
+ *
384
+ * The CLI opens the user's default browser to claude.com OAuth, waits
385
+ * for the user to authorize, and writes ~/.claude/.credentials.json
386
+ * with the `claudeAiOauth` block on success. Our daemon doesn't block
387
+ * on subprocess completion — we let it run in the background. The
388
+ * companion file watcher detects the credentials file mutation and
389
+ * notifies the worker.
390
+ *
391
+ * Returns the subprocess PID so callers can ack the launch back to
392
+ * the worker (the UI shows "browser tab opening / authorize there").
393
+ * If the subprocess exits with an error code, we surface stderr so
394
+ * users get diagnostic information.
395
+ */
396
+ export async function startPlanOauth(opts) {
397
+ const binary = await findClaudeBinary();
398
+ if (!binary) {
399
+ return {
400
+ ok: false,
401
+ error: 'Could not find the Claude Code binary. Install at https://claude.com/download or via the Mac app reinstall.',
402
+ };
403
+ }
404
+ let child;
405
+ try {
406
+ child = spawn(binary, ['setup-token'], {
407
+ stdio: ['ignore', 'pipe', 'pipe'],
408
+ // Inherit env so the subprocess can read the user's existing
409
+ // ~/.claude/ config + open their browser via xdg-open / open.
410
+ env: { ...process.env },
411
+ detached: false,
412
+ });
413
+ }
414
+ catch (err) {
415
+ return { ok: false, error: `Could not spawn claude setup-token: ${err?.message ?? err}` };
416
+ }
417
+ // Accumulate stderr for diagnostic surfacing if the subprocess fails.
418
+ let stderrTail = '';
419
+ child.stdout?.on('data', (d) => {
420
+ const chunk = d.toString('utf8');
421
+ if (opts?.onOutput)
422
+ opts.onOutput(chunk);
423
+ });
424
+ child.stderr?.on('data', (d) => {
425
+ const chunk = d.toString('utf8');
426
+ stderrTail = (stderrTail + chunk).slice(-2000); // bounded buffer
427
+ if (opts?.onOutput)
428
+ opts.onOutput(chunk);
429
+ });
430
+ child.on('exit', (code) => {
431
+ if (opts?.onExit)
432
+ opts.onExit(code, stderrTail);
433
+ });
434
+ return { ok: true, pid: child.pid ?? -1, binary };
435
+ }
436
+ /**
437
+ * Read Claude Code's auth file from whichever location currently holds it.
438
+ *
439
+ * Tries the canonical `.credentials.json` first; falls back to the legacy
440
+ * `credentials.json` (no dot) that older Claude Code releases used. When
441
+ * only the legacy path has a credential, best-effort symlinks it to the
442
+ * canonical path so the Anthropic Agent SDK — which hard-codes
443
+ * `~/.claude/.credentials.json` — picks up the same token without the
444
+ * user re-running `claude setup-token`. Symlink failure is non-fatal:
445
+ * we still return the parsed credential so the worker-side gate
446
+ * (workspace.ts `agent:claude-prompt` cred check) gets notified; only
447
+ * the SDK side stays broken until the symlink can be created.
448
+ *
449
+ * Returns { parsed, sourcePath } on hit, null on miss.
450
+ */
451
+ async function readClaudeCodeAuthFile() {
452
+ // Canonical first.
453
+ try {
454
+ const raw = await fs.readFile(CLAUDE_CREDENTIALS_FILE, 'utf8');
455
+ return { parsed: JSON.parse(raw), sourcePath: CLAUDE_CREDENTIALS_FILE };
456
+ }
457
+ catch (err) {
458
+ if (err?.code !== 'ENOENT')
459
+ throw err;
460
+ }
461
+ // Legacy fallback — bridge to canonical via symlink so the SDK sees it.
462
+ try {
463
+ const raw = await fs.readFile(CLAUDE_CREDENTIALS_LEGACY_FILE, 'utf8');
464
+ const parsed = JSON.parse(raw);
465
+ try {
466
+ await fs.symlink(CLAUDE_CREDENTIALS_LEGACY_FILE, CLAUDE_CREDENTIALS_FILE);
467
+ // eslint-disable-next-line no-console
468
+ console.log(`[claude-credential-store] bridged legacy auth: symlinked ${CLAUDE_CREDENTIALS_LEGACY_FILE} → ${CLAUDE_CREDENTIALS_FILE}`);
469
+ }
470
+ catch (linkErr) {
471
+ // EEXIST means the canonical file was created in the gap between
472
+ // our canonical-read above (which returned ENOENT) and the symlink
473
+ // attempt — race with `claude setup-token` running concurrently.
474
+ // Harmless: re-read on the next watcher tick. Anything else (EACCES,
475
+ // EROFS, etc.) we surface so the operator knows the SDK side won't
476
+ // pick up the token even though the worker side will.
477
+ if (linkErr?.code !== 'EEXIST') {
478
+ // eslint-disable-next-line no-console
479
+ console.warn(`[claude-credential-store] could not symlink ${CLAUDE_CREDENTIALS_LEGACY_FILE} → ${CLAUDE_CREDENTIALS_FILE}: ${linkErr?.message ?? linkErr}`);
480
+ }
481
+ }
482
+ return { parsed, sourcePath: CLAUDE_CREDENTIALS_LEGACY_FILE };
483
+ }
484
+ catch (err) {
485
+ if (err?.code === 'ENOENT')
486
+ return null;
487
+ throw err;
488
+ }
489
+ }
490
+ /**
491
+ * Watch ~/.claude/.credentials.json (and the legacy `credentials.json`)
492
+ * and fire onDetected whenever a `claudeAiOauth.accessToken` appears
493
+ * (or changes).
494
+ *
495
+ * Implementation notes:
496
+ * - fs.watch on the parent dir, filtered to either credentials
497
+ * filename. Watching files directly is fragile because the CLI
498
+ * uses atomic-write (write to a temp file, rename over) which fires
499
+ * 'rename' events the file watcher can't track.
500
+ * - Coalesce events with a small debounce — atomic writes typically
501
+ * produce 2-3 events back-to-back.
502
+ * - We don't track per-token changes; the worker only cares about
503
+ * "from no token → token present" transitions (treats subsequent
504
+ * refreshes as no-ops).
505
+ *
506
+ * Returns an unsubscribe function the caller invokes to stop the watch.
507
+ */
508
+ export function watchClaudeCredentialsFile(onDetected) {
509
+ const dir = dirname(CLAUDE_CREDENTIALS_FILE);
510
+ const canonicalFilename = '.credentials.json';
511
+ const legacyFilename = 'credentials.json';
512
+ let lastSeenKey = null;
513
+ let debounceTimer = null;
514
+ const evaluate = async () => {
515
+ try {
516
+ const result = await readClaudeCodeAuthFile();
517
+ if (!result) {
518
+ // No credential in either location. Reset state so a subsequent
519
+ // appearance is treated as a new event.
520
+ lastSeenKey = null;
521
+ return;
522
+ }
523
+ const parsed = result.parsed;
524
+ // Plan-SDK OAuth shape: { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
525
+ if (parsed?.claudeAiOauth?.accessToken) {
526
+ const token = String(parsed.claudeAiOauth.accessToken);
527
+ if (token === lastSeenKey)
528
+ return; // no change
529
+ lastSeenKey = token;
530
+ const masked = `Pro/Max plan (…${token.slice(-4)})`;
531
+ onDetected({
532
+ type: 'plan',
533
+ maskedKey: masked,
534
+ expiresAt: typeof parsed.claudeAiOauth.expiresAt === 'number' ? parsed.claudeAiOauth.expiresAt : undefined,
535
+ scopes: Array.isArray(parsed.claudeAiOauth.scopes) ? parsed.claudeAiOauth.scopes : undefined,
536
+ });
537
+ return;
538
+ }
539
+ // API-key shape: { primaryApiKey: 'sk-ant-...' } or similar — included for
540
+ // completeness if a user runs `claude --api-key`. Treated as 'api-key' type.
541
+ if (typeof parsed?.primaryApiKey === 'string' && parsed.primaryApiKey.startsWith('sk-ant-')) {
542
+ const key = parsed.primaryApiKey;
543
+ if (key === lastSeenKey)
544
+ return;
545
+ lastSeenKey = key;
546
+ onDetected({
547
+ type: 'api-key',
548
+ maskedKey: `${key.slice(0, 8)}...${key.slice(-4)}`,
549
+ });
550
+ }
551
+ }
552
+ catch {
553
+ // Parse errors are normal during atomic-write windows; ignore.
554
+ }
555
+ };
556
+ // Initial poll in case the file already exists.
557
+ evaluate();
558
+ let watcher;
559
+ try {
560
+ watcher = fsWatch(dir, { persistent: false }, (_event, name) => {
561
+ if (name !== canonicalFilename && name !== legacyFilename)
562
+ return;
563
+ if (debounceTimer)
564
+ clearTimeout(debounceTimer);
565
+ debounceTimer = setTimeout(evaluate, 200);
566
+ });
567
+ }
568
+ catch (err) {
569
+ // ~/.claude/ may not exist yet (fresh install). Create it then watch.
570
+ // If creation fails too, we silently no-op — the OAuth flow will
571
+ // create the dir on first run, and a future call to this function
572
+ // can succeed.
573
+ try {
574
+ const fsSync = require('node:fs');
575
+ fsSync.mkdirSync(dir, { recursive: true });
576
+ watcher = fsWatch(dir, { persistent: false }, (_event, name) => {
577
+ if (name !== canonicalFilename && name !== legacyFilename)
578
+ return;
579
+ if (debounceTimer)
580
+ clearTimeout(debounceTimer);
581
+ debounceTimer = setTimeout(evaluate, 200);
582
+ });
583
+ }
584
+ catch {
585
+ return () => { };
586
+ }
587
+ }
588
+ return () => {
589
+ if (debounceTimer)
590
+ clearTimeout(debounceTimer);
591
+ try {
592
+ watcher.close();
593
+ }
594
+ catch { /* idempotent */ }
595
+ };
596
+ }
597
+ // ── Internal: security(1) wrapper ────────────────────────────────────
598
+ function runSecurity(args) {
599
+ return new Promise((resolve, reject) => {
600
+ const child = spawn('security', args, { stdio: ['ignore', 'pipe', 'pipe'] });
601
+ let out = '';
602
+ let err = '';
603
+ child.stdout.on('data', (d) => { out += d.toString('utf8'); });
604
+ child.stderr.on('data', (d) => { err += d.toString('utf8'); });
605
+ child.on('close', (code) => {
606
+ if (code === 0) {
607
+ resolve(out);
608
+ return;
609
+ }
610
+ /**
611
+ * ⚠⚠ "NOT FOUND" IS AN ABSENCE, NOT A FAILURE — and reporting it as one is where the scary
612
+ * keychain text comes from. `security` exits 44 with
613
+ *
614
+ * security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
615
+ *
616
+ * for the perfectly ordinary case of reading a secret that was never stored, or deleting one
617
+ * that is already gone. Rejecting on it turns a routine miss into an error a user reads as
618
+ * "the keychain is broken" — and callers then log it, because from their side it arrived as a
619
+ * thrown Error rather than an empty answer.
620
+ *
621
+ * ⚠ ONLY THE NOT-FOUND CODE. A real failure — locked keychain, denied access, malformed
622
+ * arguments — still rejects with the full text, because those are the cases where the
623
+ * message is the whole diagnosis.
624
+ */
625
+ if (code === 44 || /could not be found in the keychain/i.test(err)) {
626
+ resolve('');
627
+ return;
628
+ }
629
+ reject(new Error(`security ${args[0]} failed (${code}): ${err.trim() || '(no stderr)'}`));
630
+ });
631
+ child.on('error', reject);
632
+ });
633
+ }