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
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "beehaven",
3
+ "version": "0.8.16",
4
+ "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "beehaven": "dist/cli.js",
9
+ "clearly": "dist/clearly.js"
10
+ },
11
+ "main": "dist/cli.js",
12
+ "files": [
13
+ "dist",
14
+ "python",
15
+ "README.md",
16
+ "LICENSE",
17
+ "scripts/",
18
+ "skills"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org/"
26
+ },
27
+ "scripts": {
28
+ "dev": "tsx watch src/cli.ts",
29
+ "build": "tsc && node scripts/stamp-build-channel.mjs && node scripts/sync-installed.mjs",
30
+ "start": "node dist/cli.js",
31
+ "test": "vitest run",
32
+ "test:json-capable": "vitest run src/__tests__/json-capable.test.ts",
33
+ "test:limit-capable": "vitest run src/__tests__/limit-capable.test.ts",
34
+ "test:out-capable": "vitest run src/__tests__/out-capable.test.ts",
35
+ "test:script-citations": "vitest run src/__tests__/script-citations.test.ts",
36
+ "prepublishOnly": "BEEHAVEN_PUBLIC_BUILD=1 BEEHAVEN_NO_SYNC=1 npm run build",
37
+ "sync:skills": "node scripts/sync-skills.mjs",
38
+ "prepack": "node scripts/sync-skills.mjs"
39
+ },
40
+ "dependencies": {
41
+ "@anthropic-ai/claude-agent-sdk": "^0.2.141",
42
+ "bplist-parser": "^0.3.2",
43
+ "chokidar": "4.0.0",
44
+ "fflate": "^0.8.3",
45
+ "ws": "8.18.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@clearly-sh/beehaven-core": ">=0.3.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@clearly-sh/beehaven-core": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "optionalDependencies": {
56
+ "better-sqlite3": "11.0.0",
57
+ "node-pty": "1.0.0",
58
+ "open": "10.1.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^22.0.0",
62
+ "@types/ws": "^8.5.7",
63
+ "tsx": "^4.19.0",
64
+ "typescript": "^5.6.0",
65
+ "vitest": "^4.0.0"
66
+ },
67
+ "keywords": [
68
+ "beehaven",
69
+ "clearly",
70
+ "cli",
71
+ "ai-agents",
72
+ "agent-workspace",
73
+ "claude-code",
74
+ "codex",
75
+ "agent-tools",
76
+ "design-canvas",
77
+ "mcp"
78
+ ],
79
+ "license": "MIT",
80
+ "homepage": "https://www.clearly.sh/docs/cli",
81
+ "bugs": {
82
+ "url": "https://github.com/clearly-sh/clearly/issues"
83
+ },
84
+ "repository": {
85
+ "type": "git",
86
+ "url": "https://github.com/clearly-sh/clearly.git"
87
+ }
88
+ }
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ qwen3_tts.py — the MLX side of the `qwen3-mlx` TTS provider for `beehaven speak`.
4
+
5
+ WHY THIS EXISTS. Every other voice path `speak` can take is a network call
6
+ (cloud Kokoro, ElevenLabs) or a canned system voice (macOS `say`). None of them
7
+ can speak in *your* voice, and the cloud ones need a relay + a live token. This
8
+ script is the on-device path: Alibaba's Qwen3-TTS running under Apple's MLX,
9
+ which does 3-second voice cloning locally on Apple Silicon. No relay, no API
10
+ key, no audio leaves the machine.
11
+
12
+ CONTRACT WITH THE NODE SIDE (`src/tts-qwen3.ts`).
13
+ - One JSON request object on **stdin**, one JSON response object on **stdout**.
14
+ - stdout carries NOTHING ELSE. mlx-audio prints progress, tokens/sec, and
15
+ "✅ Audio successfully generated" to stdout unconditionally, so every call
16
+ into it is wrapped in `redirect_stdout(sys.stderr)`. The Node side parses
17
+ stdout strictly, so a single stray print would corrupt the response.
18
+ - Failures come back as `{"ok": false, "error": "..."}` with exit code 1 —
19
+ the caller gets a message, never a Python traceback in the user's terminal.
20
+
21
+ COMMANDS
22
+ probe {} -> versions + whether Metal is usable
23
+ transcribe {audio} -> {text} (whisper, clone-time only)
24
+ speak {text, out, ...} -> {path, ...} (synthesis)
25
+
26
+ WHY TRANSCRIBE IS A SEPARATE COMMAND. Qwen3-TTS clones via in-context learning:
27
+ it needs the reference audio AND a transcript of it. mlx-audio will happily
28
+ auto-transcribe at synthesis time, but that loads whisper-large-v3-turbo
29
+ (~1.6 GB) on *every* utterance. We transcribe ONCE when the voice is cloned,
30
+ persist the transcript in the voice profile, and pass `ref_text` on every
31
+ subsequent `speak` — so whisper never loads again and the profile stays a
32
+ self-contained, portable description of the voice.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import sys
39
+ import time
40
+ import wave
41
+ from contextlib import redirect_stdout
42
+ from pathlib import Path
43
+
44
+ # Model families. Voice CLONING requires a `-Base` checkpoint: CustomVoice is
45
+ # fixed predefined speakers and VoiceDesign builds a voice from a text
46
+ # description — neither accepts a reference recording.
47
+ DEFAULT_MODEL = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-4bit"
48
+ DEFAULT_STT_MODEL = "mlx-community/whisper-large-v3-turbo-asr-fp16"
49
+
50
+
51
+ def _fail(msg: str) -> None:
52
+ """Emit the error envelope on stdout and leave with a non-zero status."""
53
+ json.dump({"ok": False, "error": str(msg)}, sys.stdout)
54
+ sys.stdout.write("\n")
55
+ sys.stdout.flush()
56
+ sys.exit(1)
57
+
58
+
59
+ def _ok(payload: dict) -> None:
60
+ json.dump({"ok": True, **payload}, sys.stdout)
61
+ sys.stdout.write("\n")
62
+ sys.stdout.flush()
63
+
64
+
65
+ def cmd_probe(_req: dict) -> dict:
66
+ import importlib.metadata as md
67
+
68
+ import mlx.core as mx
69
+
70
+ def _ver(pkg: str) -> str:
71
+ try:
72
+ return md.version(pkg)
73
+ except Exception:
74
+ return "?"
75
+
76
+ # Touching the default device is the cheapest proof Metal actually
77
+ # initializes — an x86 Python under Rosetta imports mlx fine and then dies
78
+ # at the first kernel launch.
79
+ device = str(mx.default_device())
80
+ return {
81
+ "python": sys.version.split()[0],
82
+ "mlxAudio": _ver("mlx-audio"),
83
+ "mlx": _ver("mlx"),
84
+ "device": device,
85
+ "defaultModel": DEFAULT_MODEL,
86
+ "sttModel": DEFAULT_STT_MODEL,
87
+ }
88
+
89
+
90
+ def cmd_transcribe(req: dict) -> dict:
91
+ audio = req.get("audio")
92
+ if not audio:
93
+ raise ValueError("transcribe requires `audio`")
94
+ audio_path = Path(audio).expanduser()
95
+ if not audio_path.exists():
96
+ raise FileNotFoundError(f"reference audio not found: {audio_path}")
97
+
98
+ model_id = req.get("sttModel") or DEFAULT_STT_MODEL
99
+ started = time.time()
100
+ with redirect_stdout(sys.stderr):
101
+ from mlx_audio.stt import load as load_stt
102
+
103
+ stt = load_stt(model_id)
104
+ # whisper takes the path directly and handles decode + resample, so we
105
+ # don't have to guess the TTS model's sample rate here.
106
+ result = stt.generate(str(audio_path), language=req.get("language"))
107
+ text = (getattr(result, "text", "") or "").strip()
108
+ if not text:
109
+ raise RuntimeError(
110
+ "transcription came back empty — is the reference audio silent or music-only?"
111
+ )
112
+ return {"text": text, "model": model_id, "elapsedSec": round(time.time() - started, 2)}
113
+
114
+
115
+ def cmd_speak(req: dict) -> dict:
116
+ text = (req.get("text") or "").strip()
117
+ if not text:
118
+ raise ValueError("speak requires `text`")
119
+ out = req.get("out")
120
+ if not out:
121
+ raise ValueError("speak requires `out` (destination .wav path)")
122
+
123
+ out_path = Path(out).expanduser()
124
+ out_path.parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ ref_audio = req.get("refAudio")
127
+ ref_text = req.get("refText")
128
+ if ref_audio:
129
+ ref_audio = str(Path(ref_audio).expanduser())
130
+ if not Path(ref_audio).exists():
131
+ raise FileNotFoundError(f"reference audio not found: {ref_audio}")
132
+
133
+ kwargs = {
134
+ "text": text,
135
+ "model": req.get("model") or DEFAULT_MODEL,
136
+ # `join_audio` makes mlx-audio write exactly ONE file at
137
+ # "<file_prefix>.<audio_format>" instead of one file per sentence
138
+ # segment — that predictable name is how we find the result.
139
+ "join_audio": True,
140
+ "output_path": str(out_path.parent),
141
+ "file_prefix": out_path.stem,
142
+ "audio_format": out_path.suffix.lstrip(".") or "wav",
143
+ "verbose": False,
144
+ "play": False,
145
+ "temperature": float(req.get("temperature", 0.7)),
146
+ "speed": float(req.get("speed", 1.0)),
147
+ "lang_code": req.get("langCode") or "en",
148
+ }
149
+ if ref_audio:
150
+ kwargs["ref_audio"] = ref_audio
151
+ # A Base checkpoint in ICL mode takes its identity entirely from the
152
+ # reference, so blank the speaker — otherwise mlx-audio's `af_heart`
153
+ # default (a Kokoro speaker name) rides along and muddies the logs.
154
+ kwargs["voice"] = None
155
+ # Passing ref_text keeps mlx-audio from loading whisper. When a caller
156
+ # skips it (bare `--ref-audio` with no cached profile) mlx-audio falls
157
+ # back to transcribing, which is slow but correct.
158
+ if ref_text:
159
+ kwargs["ref_text"] = ref_text
160
+ else:
161
+ kwargs["stt_model"] = req.get("sttModel") or DEFAULT_STT_MODEL
162
+ elif req.get("voice"):
163
+ # No reference recording: a predefined speaker (CustomVoice checkpoints).
164
+ kwargs["voice"] = req["voice"]
165
+ if req.get("instruct"):
166
+ kwargs["instruct"] = req["instruct"]
167
+ if req.get("maxTokens"):
168
+ kwargs["max_tokens"] = int(req["maxTokens"])
169
+
170
+ started = time.time()
171
+ with redirect_stdout(sys.stderr):
172
+ from mlx_audio.tts.generate import generate_audio
173
+
174
+ generate_audio(**kwargs)
175
+
176
+ if not out_path.exists():
177
+ raise RuntimeError(
178
+ f"synthesis reported success but produced no file at {out_path}"
179
+ )
180
+
181
+ duration = None
182
+ try:
183
+ with wave.open(str(out_path), "rb") as wf:
184
+ duration = round(wf.getnframes() / float(wf.getframerate()), 2)
185
+ except Exception:
186
+ # Non-wav container (flac/mp3) — duration is a nicety, not a contract.
187
+ pass
188
+
189
+ elapsed = round(time.time() - started, 2)
190
+ return {
191
+ "path": str(out_path),
192
+ "bytes": out_path.stat().st_size,
193
+ "durationSec": duration,
194
+ "elapsedSec": elapsed,
195
+ # <1.0 means we generated faster than real time — the number that
196
+ # decides whether this provider is usable interactively.
197
+ "realTimeFactor": round(elapsed / duration, 2) if duration else None,
198
+ "model": kwargs["model"],
199
+ "cloned": bool(ref_audio),
200
+ }
201
+
202
+
203
+ COMMANDS = {"probe": cmd_probe, "transcribe": cmd_transcribe, "speak": cmd_speak}
204
+
205
+
206
+ def main() -> None:
207
+ command = sys.argv[1] if len(sys.argv) > 1 else ""
208
+ handler = COMMANDS.get(command)
209
+ if handler is None:
210
+ _fail(f"unknown command {command!r} — expected one of {', '.join(COMMANDS)}")
211
+ return
212
+
213
+ raw = sys.stdin.read().strip()
214
+ try:
215
+ req = json.loads(raw) if raw else {}
216
+ except json.JSONDecodeError as exc:
217
+ _fail(f"invalid JSON request: {exc}")
218
+ return
219
+
220
+ try:
221
+ _ok(handler(req))
222
+ except Exception as exc: # noqa: BLE001 — the envelope IS the error channel
223
+ import traceback
224
+
225
+ traceback.print_exc(file=sys.stderr)
226
+ _fail(f"{type(exc).__name__}: {exc}")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postbuild — declare, in the emitted bundle, whether this is a PUBLIC build.
4
+ *
5
+ * ⚠⚠ WHAT THIS DECIDES. `PUBLIC_BUILD` is what makes `HIVE_DEV_MODE=1` inert. Developer scope
6
+ * mints a second IPC token that unlocks `get-token` (the user's Firebase ID token), `shell`, `sql`,
7
+ * `marketplace:install`, the `admin:*` proxies, and — on the relay path — `bash` /
8
+ * `workspace-exec`, i.e. arbitrary local command execution. None of that belongs in a build handed
9
+ * to `curl … | sh` users.
10
+ *
11
+ * ⚠ IT REWRITES `dist/`, NEVER `src/`. Stamping the source would leave the working tree dirty after
12
+ * a publish and, far worse, could be committed — shipping a checkout that silently cannot enable
13
+ * developer mode, with no error to explain it.
14
+ *
15
+ * ⚠ THE DEFAULT IS NOT PUBLIC. An ordinary `npm run build` writes `false`, so a checkout behaves as
16
+ * it always has. `scripts/publish-beehaven-cli.sh` sets `BEEHAVEN_PUBLIC_BUILD=1`. Defaulting the
17
+ * other way would mean one forgotten flag ships a developer build to everyone, silently.
18
+ *
19
+ * ⚠ IT FAILS THE BUILD RATHER THAN WARNING. A stamp that quietly did nothing is exactly the shape
20
+ * of bug this exists to prevent: the tarball would look fine and carry the capability.
21
+ */
22
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
28
+ /**
29
+ * ⚠ `BEEHAVEN_STAMP_FILE` IS A TEST SEAM, and it exists because the refusal below is the script's
30
+ * whole safety property — an unstamped build looks perfect and carries the wrong answer. The only
31
+ * way to prove the refusal fires is to run this script against a deliberately-broken file, and
32
+ * doing that to the real `dist/` would break every other test that execs the built CLI. Unset in
33
+ * every real build, so the default path is unchanged.
34
+ */
35
+ const FILE = process.env.BEEHAVEN_STAMP_FILE || join(ROOT, 'dist', 'build-channel.js');
36
+
37
+ const truthy = (v) => ['1', 'true', 'yes', 'on'].includes(String(v ?? '').trim().toLowerCase());
38
+ const isPublic = truthy(process.env.BEEHAVEN_PUBLIC_BUILD);
39
+
40
+ /**
41
+ * WHICH BUILD — CBH2-145. A commit and a build time, so `--version` can tell two builds of 0.8.2
42
+ * apart. Before this, the Mac app's bundled `cli.js` and the repo build both said `0.8.2`, differed
43
+ * by 9 KB, and disagreed about which verbs exist.
44
+ *
45
+ * ⚠⚠ GIT IS OPTIONAL, AND THAT IS NOT LAZINESS. A user who unpacks the published tarball and runs
46
+ * `npm run build` has no `.git`, and failing their build to protect a diagnostic field would be a
47
+ * strictly worse outcome than an unstamped sha. The TIMESTAMP has no such dependency, so the
48
+ * "which build" question is answered either way — which is why there are two fields and not one.
49
+ *
50
+ * ⚠ `-dirty` IS SCOPED TO THIS PACKAGE (`git status --porcelain -- <pkgRoot>`). Against the whole
51
+ * monorepo the marker would be on in essentially every dev build and would therefore carry no
52
+ * information. Scoped, it means precisely "the sha does not identify these bytes".
53
+ *
54
+ * ⚠ ITS BOUND, STATED: the CLI also links `@clearly-sh/beehaven-core`, whose sources live outside
55
+ * this path. A core-only change leaves the marker clean. The sha still identifies the CLI sources,
56
+ * which is what this file is emitted from.
57
+ */
58
+ function gitSha() {
59
+ const git = (args) =>
60
+ execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
61
+ try {
62
+ const sha = git(['rev-parse', '--short=12', 'HEAD']);
63
+ if (!sha) return '';
64
+ const dirty = git(['status', '--porcelain', '--', ROOT]).length > 0;
65
+ return dirty ? `${sha}-dirty` : sha;
66
+ } catch {
67
+ return ''; // no git, no repo, shallow checkout — all legitimate
68
+ }
69
+ }
70
+
71
+ const sha = gitSha();
72
+ // ⚠ Minute precision, UTC. Seconds add noise to a field a human reads; a timezone adds ambiguity.
73
+ const builtAt = new Date().toISOString().replace(/:\d\d\.\d+Z$/, 'Z');
74
+
75
+ if (!existsSync(FILE)) {
76
+ console.error(`stamp-build-channel: ${FILE} does not exist — did tsc emit? Refusing to continue.`);
77
+ process.exit(1);
78
+ }
79
+
80
+ const src = readFileSync(FILE, 'utf8');
81
+
82
+ /**
83
+ * ⚠ EVERY STAMP IS ANCHORED ON THE DECLARATION TSC EMITS, so a rename fails loudly instead of
84
+ * stamping nothing. That refusal is the whole safety property of this script — an unstamped build
85
+ * looks perfect and carries the wrong answer — so a NEW field gets the same treatment rather than a
86
+ * best-effort `replace`.
87
+ */
88
+ const STAMPS = [
89
+ { name: 'PUBLIC_BUILD', decl: /export const PUBLIC_BUILD = (?:true|false);/, next: `export const PUBLIC_BUILD = ${isPublic};` },
90
+ { name: 'BUILD_SHA', decl: /export const BUILD_SHA = '[^']*';/, next: `export const BUILD_SHA = '${sha}';` },
91
+ { name: 'BUILD_TIME', decl: /export const BUILD_TIME = '[^']*';/, next: `export const BUILD_TIME = '${builtAt}';` },
92
+ ];
93
+
94
+ let out = src;
95
+ for (const s of STAMPS) {
96
+ if (!s.decl.test(out)) {
97
+ console.error(`stamp-build-channel: could not find the ${s.name} declaration in the emitted `
98
+ + 'build-channel.js. The export was renamed or the emit shape changed — fix this script rather '
99
+ + 'than shipping an unstamped build.');
100
+ process.exit(1);
101
+ }
102
+ out = out.replace(s.decl, s.next);
103
+ }
104
+ writeFileSync(FILE, out);
105
+
106
+ // ⚠ Read it BACK. The whole point is that the shipped bytes say what we think they say.
107
+ const after = readFileSync(FILE, 'utf8');
108
+ for (const s of STAMPS) {
109
+ if (!after.includes(s.next)) {
110
+ console.error(`stamp-build-channel: the ${s.name} stamp did not take. Refusing to continue.`);
111
+ process.exit(1);
112
+ }
113
+ }
114
+ if (isPublic) console.log(' build channel: PUBLIC — Developer Mode is compiled out');
115
+ console.log(` build id: ${sha || '(no git)'} ${builtAt}`);
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postbuild — keep the INSTALLED cli in step with the one you just built.
4
+ *
5
+ * ── THE TRAP THIS CLOSES ─────────────────────────────────────────────────────────────
6
+ * `curl … install.sh` writes `~/.local/bin/beehaven`, a wrapper that execs
7
+ * `~/.beehaven/cli/dist/cli.js`. `~/.local/bin` precedes the npm prefix on PATH, so after
8
+ * `npm run build` in this package the binary named `beehaven` is STILL the installed copy —
9
+ * and both report the same version, because the number comes from a package.json neither
10
+ * build bumps on a rebuild.
11
+ *
12
+ * The failure mode is not a broken CLI, it is a LYING one: you fix a bug, rebuild, re-run,
13
+ * observe the old behaviour, and conclude the fix didn't work. That happened during this
14
+ * feature's own development and cost a verified-correct fix an extra debugging cycle.
15
+ *
16
+ * ── WHAT THIS DOES ───────────────────────────────────────────────────────────────────
17
+ * If (and only if) `~/.beehaven/cli` already exists — i.e. this machine has an installed
18
+ * copy — mirror the freshly-built `dist/`, `skills/`, and `package.json` into it. A machine with no
19
+ * install is untouched, so this is a no-op in CI and on a fresh clone.
20
+ *
21
+ * It deliberately does NOT create the install: writing a new CLI into a user's home on the
22
+ * strength of a `npm run build` would be a side effect nobody asked for. It only keeps an
23
+ * EXISTING one honest.
24
+ *
25
+ * Opt out with BEEHAVEN_NO_SYNC=1 (e.g. when you are deliberately testing an older install).
26
+ */
27
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
28
+ import { execFileSync } from 'node:child_process';
29
+ import { homedir } from 'node:os';
30
+ import { join, dirname } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const pkgRoot = join(here, '..');
35
+ const installed = join(homedir(), '.beehaven', 'cli');
36
+
37
+ if (process.env.BEEHAVEN_NO_SYNC === '1') {
38
+ console.log(' postbuild: sync skipped (BEEHAVEN_NO_SYNC=1)');
39
+ process.exit(0);
40
+ }
41
+ /**
42
+ * ⚠⚠ A THIRD COPY THIS SCRIPT DOES NOT OWN: THE MAC APP'S BUNDLE.
43
+ *
44
+ * The header above describes closing the `~/.local/bin` trap by syncing `~/.beehaven/cli`. That is
45
+ * still right and still incomplete — on a machine with the Mac app installed, the wrapper at
46
+ * `~/.local/bin/beehaven` says `managed-by: clearly-mac-app` and `overwritten on every app launch`,
47
+ * and it execs the app's OWN bundled `cli.js`. So the binary named `beehaven` is neither this build
48
+ * nor the one we just synced.
49
+ *
50
+ * ⚠ IT IS THE EXACT FAILURE THE HEADER NAMES — "you fix a bug, rebuild, re-run, observe the old
51
+ * behaviour, and conclude the fix didn't work" — and it cost a cycle again on 2026-09-01, on a
52
+ * daemon-startup fix that was correct and simply was not the code being run.
53
+ *
54
+ * ⚠ WE WARN RATHER THAN WRITE. Copying into `/Applications` on every build is invasive, needs
55
+ * permissions this script should not want, and would be undone by the next app launch anyway. What
56
+ * the reader needs is to know which binary they are about to test.
57
+ */
58
+ const wrapper = join(homedir(), '.local', 'bin', 'beehaven');
59
+ try {
60
+ if (existsSync(wrapper)) {
61
+ const body = readFileSync(wrapper, 'utf8');
62
+ if (/managed-by:\s*clearly-mac-app/.test(body)) {
63
+ console.warn(
64
+ ' postbuild: ⚠ `beehaven` on your PATH is the MAC APP\'s bundled CLI, which this build does\n' +
65
+ ' not update. Test this build with `node dist/cli.js …`, or relaunch the app\n' +
66
+ ' after copying, or the old behaviour will look like your fix failing.'
67
+ );
68
+ }
69
+ }
70
+ } catch { /* a warning must never fail a build */ }
71
+
72
+ if (!existsSync(installed)) {
73
+ // No install on this machine — nothing to keep in step. Silent: this is the normal state
74
+ // in CI, and a warning nobody can act on is noise on every build.
75
+ process.exit(0);
76
+ }
77
+
78
+ try {
79
+ const distSrc = join(pkgRoot, 'dist');
80
+ if (!existsSync(distSrc)) {
81
+ console.error(' postbuild: dist/ missing — did tsc fail? Installed copy left alone.');
82
+ process.exit(0);
83
+ }
84
+ const distDst = join(installed, 'dist');
85
+ rmSync(distDst, { recursive: true, force: true });
86
+ mkdirSync(distDst, { recursive: true });
87
+ cpSync(distSrc, distDst, { recursive: true });
88
+ cpSync(join(pkgRoot, 'package.json'), join(installed, 'package.json'));
89
+ /**
90
+ * THE SKILLS ARE PART OF THE CLI RELEASE, NOT OPTIONAL CACHE.
91
+ *
92
+ * `agent-skills.ts` resolves its source relative to the running installed `dist/`, so syncing
93
+ * only code left `~/.beehaven/cli/skills` on the previous release. `skills install` then compared
94
+ * every agent home against that stale manifest, confidently reported "current", and never
95
+ * delivered documentation fixes made in the same build. Mirror the directory atomically enough
96
+ * for this local postbuild path: remove the old bundle, copy the just-synced hashed bundle, then
97
+ * let the normal installer preserve locally edited destination skills.
98
+ */
99
+ const skillsSrc = join(pkgRoot, 'skills');
100
+ const skillsDst = join(installed, 'skills');
101
+ if (existsSync(skillsSrc)) {
102
+ rmSync(skillsDst, { recursive: true, force: true });
103
+ cpSync(skillsSrc, skillsDst, { recursive: true });
104
+ }
105
+ // `python/` carries the local-TTS bridge; without it a synced install reports an
106
+ // incomplete qwen3-mlx runtime rather than working. Copy it when the source has it.
107
+ const py = join(pkgRoot, 'python');
108
+ if (existsSync(py)) cpSync(py, join(installed, 'python'), { recursive: true });
109
+ /**
110
+ * LINK THE OPTIONAL PEER DEP.
111
+ *
112
+ * `agent-identity-daemon` does a bare `await import('@clearly-sh/beehaven-core/server')`
113
+ * with no fallback. The monorepo resolves that through a workspace symlink; the standalone
114
+ * install does NOT have the package at all — so local agent minting fails there with
115
+ * "Cannot find package", which surfaces to a user as "Could not mint identity. Is the
116
+ * daemon running?" — a message about the wrong thing entirely.
117
+ *
118
+ * Copying dist without this leaves an install whose daemon cannot mint. So the sync links
119
+ * it, from the monorepo that just built it. Safe by construction: this whole script only
120
+ * runs on a machine that has BOTH an install and this source tree.
121
+ */
122
+ const coreSrc = join(pkgRoot, '..', 'core');
123
+ const scopeDir = join(installed, 'node_modules', '@clearly-sh');
124
+ if (existsSync(coreSrc)) {
125
+ try {
126
+ mkdirSync(scopeDir, { recursive: true });
127
+ const link = join(scopeDir, 'beehaven-core');
128
+ rmSync(link, { recursive: true, force: true });
129
+ symlinkSync(coreSrc, link, 'dir');
130
+ } catch (e) {
131
+ console.error(` postbuild: could not link @clearly-sh/beehaven-core (${e?.message ?? e})`);
132
+ }
133
+ }
134
+
135
+ // VERIFY BEFORE CLAIMING. A sync that leaves a CLI which cannot start is worse than a stale
136
+ // one, because the staleness at least still works. Run the synced entry point; if it can't
137
+ // even print its version, say so loudly rather than reporting success.
138
+ try {
139
+ execFileSync(process.execPath, [join(distDst, 'cli.js'), '--version'], { stdio: 'pipe', timeout: 20_000 });
140
+ } catch (e) {
141
+ console.error(' postbuild: SYNCED BUILD DOES NOT RUN — the installed CLI may be broken.');
142
+ console.error(` ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`);
143
+ process.exit(0);
144
+ }
145
+
146
+ // A marker the CLI can read to say WHERE the installed bytes came from, so
147
+ // `beehaven --version` on the installed copy can name its origin instead of implying it is
148
+ // a release.
149
+ writeFileSync(
150
+ join(installed, '.synced-from'),
151
+ JSON.stringify({ from: pkgRoot, at: new Date().toISOString() }, null, 2),
152
+ );
153
+ console.log(` postbuild: synced → ${installed}`);
154
+ console.log(` Verify selection with \`beehaven --version\`; an equal/newer Mac bundle may still win.`);
155
+ } catch (err) {
156
+ // Never fail the build over this. A stale install is a nuisance; a red build over a copy
157
+ // is worse, and the runtime staleness warning still catches the case.
158
+ console.error(` postbuild: sync failed (${err?.message ?? err}) — installed copy may be stale.`);
159
+ }