beehaven 0.8.22 → 0.8.27

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.
package/dist/cli.js CHANGED
@@ -309,7 +309,8 @@ ${DEV_ONLY_HELP ? ` PROJECT — build your own Durable Object backend (HIVE_DE
309
309
  app setup <name> Re-run a hive's setup script
310
310
  install <pkg> Shorthand for "app install <pkg>"
311
311
  skills install Teach every agent about Clearly —
312
- writes the shipped SKILL.md files
312
+ writes the shipped SKILL.md files + the
313
+ AGENTS.md / GEMINI.md / CLAUDE.md block
313
314
  skills <list|sync|remove> Manage skills paired with installed hives
314
315
 
315
316
  Deep dive: beehaven help hives Mental model + install/connect/call loop
@@ -437,7 +438,9 @@ ${DEV_ONLY_HELP ? `
437
438
  ACCOUNT
438
439
  ─────────────────────────────────────────────────────────────────────
439
440
 
440
- login Sign in (browser) — primary flow
441
+ login Sign in (browser) — primary flow; also teaches
442
+ your agents (skills + AGENTS.md block;
443
+ --no-agent-setup to skip)
441
444
  login --email <e> --password <p> Self-hosted email/password (set BEEHAVEN_API_URL)
442
445
  logout Clear local credentials + keychain
443
446
  signup Open sign-up page in browser
@@ -1299,7 +1302,25 @@ async function run() {
1299
1302
  await shorthandCmd(rawVerb, process.argv.slice(3), {});
1300
1303
  return;
1301
1304
  }
1305
+ /**
1306
+ * ⚠ `--x -3000` IS A NEGATIVE COORDINATE, NOT A FLAG. node's parseArgs reads any value starting
1307
+ * with a dash as a missing argument and dies with "Option '--x' argument is ambiguous" — which is
1308
+ * how `beehaven canvas add-image … --x -3000` failed (CDS-25). Join a negative NUMBER onto the
1309
+ * coordinate/size flag before it; nothing else is rewritten.
1310
+ */
1311
+ const NUMERIC_FLAGS = new Set(['--x', '--y', '--width', '--height', '--w', '--h', '--dx', '--dy']);
1312
+ const argv = [];
1313
+ for (let i = 2; i < process.argv.length; i++) {
1314
+ const a = process.argv[i], b = process.argv[i + 1];
1315
+ if (NUMERIC_FLAGS.has(a) && typeof b === 'string' && /^-\d+(\.\d+)?$/.test(b)) {
1316
+ argv.push(`${a}=${b}`);
1317
+ i++;
1318
+ continue;
1319
+ }
1320
+ argv.push(a);
1321
+ }
1302
1322
  const { values, positionals } = parseArgs({
1323
+ args: argv,
1303
1324
  allowPositionals: true,
1304
1325
  options: {
1305
1326
  project: { type: 'string' },
@@ -1492,6 +1513,7 @@ async function run() {
1492
1513
  // Paired Claude Code skill install policy (see commands/skills.ts)
1493
1514
  'with-skills': { type: 'boolean', default: false },
1494
1515
  'no-skills': { type: 'boolean', default: false },
1516
+ 'no-agent-setup': { type: 'boolean', default: false },
1495
1517
  // ── mobile-install metadata for `beehaven teleport <build>.ipa --composition <id>`
1496
1518
  // (see commands/workspace.ts — wantMobileInstall branch).
1497
1519
  // Future: parse Info.plist / AndroidManifest.xml automatically
@@ -2918,7 +2940,7 @@ async function run() {
2918
2940
  console.error(` beehaven start --headless`);
2919
2941
  console.error(``);
2920
2942
  console.error(` The Mac tray is only required for the LOCAL hive — avatar`);
2921
- console.error(` building, voice and mood. Install: ${color.dim('https://clearly.sh/docs/beehaven')}`);
2943
+ console.error(` building, voice and mood. Install: ${color.dim('https://www.clearly.sh/docs/beehaven')}`);
2922
2944
  process.exit(1);
2923
2945
  }
2924
2946
  console.log(` Launching ${opened}…`);
@@ -2945,11 +2967,24 @@ async function run() {
2945
2967
  // omitted BEEHAVEN_HEADLESS=1, so the "restarted" daemon hit the
2946
2968
  // parent-death watchdog and exited ~3s later — restart reported a PID and
2947
2969
  // left you with nothing running. See spawnStandaloneDaemon's comment.
2948
- const { spawnStandaloneDaemon } = await import('./commands/daemon-cmd.js');
2970
+ const { spawnStandaloneDaemon, waitForServingDaemon } = await import('./commands/daemon-cmd.js');
2949
2971
  const { resolveRelayUrlForDaemon } = await import('./daemon.js');
2950
2972
  const relayUrl = resolveRelayUrlForDaemon();
2951
2973
  const pid = spawnStandaloneDaemon(values);
2952
- console.log(` Daemon restarted (PID ${pid}).`);
2974
+ // ⚠ Report the daemon that is ACTUALLY serving — see waitForServingDaemon.
2975
+ const served = await waitForServingDaemon(pid);
2976
+ if (served.ready && served.spawnedAlive) {
2977
+ console.log(` Daemon restarted (PID ${pid}). Ready — socket answering.`);
2978
+ }
2979
+ else if (served.ready) {
2980
+ console.log(` Another supervisor (the Clearly app) restarted the daemon first — PID ${served.livePids.join(', ')} is serving. Ready.`);
2981
+ }
2982
+ else {
2983
+ console.error(served.livePids.length
2984
+ ? ` Daemon process is up (PID ${served.livePids.join(', ')}) but its socket is not answering yet. 'beehaven status' shows state.`
2985
+ : ` The daemon did not stay up (spawned PID ${pid} exited). Run 'beehaven start --headless', or reopen the Clearly app.`);
2986
+ process.exitCode = 1;
2987
+ }
2953
2988
  console.log(` Relay: ${color.dim(relayUrl)}`);
2954
2989
  break;
2955
2990
  }
@@ -2970,272 +3005,20 @@ async function run() {
2970
3005
  await login(emailArg, password);
2971
3006
  break;
2972
3007
  }
2973
- // ─── Localhost loopback OAuth (firebase-tools / wrangler /
2974
- // gh / vercel / fly / supabase pattern) ─────────────────
2975
- //
2976
- // The CLI starts an ephemeral HTTP server on a free port,
2977
- // opens the user's browser to clearly.sh/cli/auth?state=…
2978
- // &redirect=http://127.0.0.1:<port>/callback. After the user
2979
- // signs in + clicks Approve, the web page POSTs the user's
2980
- // Firebase tokens directly to the loopback. The CLI captures,
2981
- // validates state, stores in keychain + config, and shuts
2982
- // down the server. Single round-trip, instant feedback.
2983
- //
2984
- // The clearly.sh web origin and the worker are NOT in the
2985
- // token-exchange path — tokens go from the browser's authed
2986
- // session straight to localhost. State (anti-CSRF) prevents
2987
- // a malicious site from sending tokens to a CLI that didn't
2988
- // request them.
2989
- const http = await import('http');
2990
- const netMod = await import('net');
2991
- const childProcess = await import('child_process');
2992
- const cryptoMod = await import('crypto');
2993
- const state = cryptoMod.randomBytes(16).toString('hex');
2994
- // Resolve the web origin from the active env so staging
2995
- // users sign in against staging's Pages alias (or local
2996
- // dev), prod users against prod.
2997
- const ENV_AUTH_ORIGINS = {
2998
- prod: 'https://clearly.sh',
2999
- staging: 'https://localhost:3000',
3000
- dev: 'https://localhost:3000',
3001
- };
3002
- let activeEnv = 'prod';
3003
- try {
3004
- const cfgPath = join(homedir(), '.beehaven', 'config.json');
3005
- if (existsSync(cfgPath)) {
3006
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
3007
- // Derive env from the canonical endpoint, not the cfg.envName
3008
- // alias (a fresh login / Mac-app install may write only `endpoint`).
3009
- const { envName } = resolveRelaySelection(cfg);
3010
- if (ENV_AUTH_ORIGINS[envName])
3011
- activeEnv = envName;
3012
- }
3013
- }
3014
- catch { }
3015
- const authOrigin = process.env.BEEHAVEN_AUTH_ORIGIN ?? ENV_AUTH_ORIGINS[activeEnv];
3016
- // Pre-warning: non-prod env requires a local dev server.
3017
- if (activeEnv !== 'prod') {
3018
- const probe = await new Promise((resolve) => {
3019
- const t = setTimeout(() => resolve(false), 800);
3020
- const sock = netMod.createConnection(3000, '127.0.0.1');
3021
- sock.once('connect', () => {
3022
- clearTimeout(t);
3023
- sock.destroy();
3024
- resolve(true);
3025
- });
3026
- sock.once('error', () => {
3027
- clearTimeout(t);
3028
- resolve(false);
3029
- });
3030
- }).catch(() => false);
3031
- if (!probe) {
3032
- console.log('');
3033
- console.log(` ${color.yellow('!')} localhost:3000 isn't responding.`);
3034
- console.log(` Run ${color.cyan('npm run dev')} (with --experimental-https) in the clearly monorepo first.`);
3035
- console.log('');
3036
- }
3037
- }
3038
- // Spin up the loopback server. OS picks a free port (port 0).
3039
- const captured = await new Promise((resolve, reject) => {
3040
- // Tiny success page the browser sees AFTER it navigates to
3041
- // the loopback. Self-contained — no external assets,
3042
- // works offline. Auto-close after 1.5s on supported
3043
- // browsers (Chrome/Firefox allow window.close on
3044
- // self-opened or scripted-navigated tabs).
3045
- const successHtml = `<!doctype html>
3046
- <html><head><meta charset="utf-8"><title>beehaven CLI signed in</title>
3047
- <style>
3048
- body { font: 15px -apple-system, sans-serif; background: #fafaf9; color: #292524;
3049
- display: grid; place-items: center; min-height: 100vh; margin: 0; }
3050
- .card { background: white; padding: 32px 40px; border-radius: 14px;
3051
- box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
3052
- max-width: 420px; }
3053
- h1 { font-size: 20px; margin: 0 0 8px; letter-spacing: -0.01em; }
3054
- p { color: #57534e; margin: 8px 0; }
3055
- .ok { color: #16a34a; }
3056
- .eyebrow { font-size: 10px; letter-spacing: .22em; color: #a8a29e;
3057
- text-transform: uppercase; margin-bottom: 8px; }
3058
- </style></head>
3059
- <body><div class="card">
3060
- <p class="eyebrow">01 — beehaven CLI</p>
3061
- <h1><span class="ok">✓</span> Signed in</h1>
3062
- <p>The CLI now has your credentials. You can close this tab and
3063
- return to your terminal.</p>
3064
- </div>
3065
- <script>setTimeout(() => { try { window.close(); } catch (e) {} }, 1500);</script>
3066
- </body></html>`;
3067
- const errorHtml = (msg) => `<!doctype html>
3068
- <html><head><meta charset="utf-8"><title>beehaven CLI error</title>
3069
- <style>body{font:15px -apple-system,sans-serif;padding:40px;color:#292524}
3070
- .err{color:#dc2626}</style></head>
3071
- <body><h1 class="err">✗ Sign-in error</h1><p>${msg}</p>
3072
- <p><small>Run <code>beehaven login</code> again from your terminal.</small></p>
3073
- </body></html>`;
3074
- const server = http.createServer((req, res) => {
3075
- if (req.method !== 'GET' || !(req.url ?? '').startsWith('/callback')) {
3076
- res.writeHead(404, { 'Content-Type': 'text/plain' });
3077
- res.end('Not Found');
3078
- return;
3079
- }
3080
- // Parse query params from the GET URL. Tokens arrive
3081
- // as ?state=…&idToken=…&refreshToken=…&userId=…
3082
- // because the web side window.location.assign'd here
3083
- // — fetch() from HTTPS to HTTP loopback is mixed-
3084
- // content blocked by every browser since 2020, so
3085
- // top-level navigation is the standard pattern (gh,
3086
- // wrangler, vercel, firebase-tools all use this).
3087
- const url = new URL(req.url, `http://127.0.0.1`);
3088
- const presentedState = url.searchParams.get('state') ?? '';
3089
- const idToken = url.searchParams.get('idToken') ?? '';
3090
- const refreshToken = url.searchParams.get('refreshToken') ?? '';
3091
- const userId = url.searchParams.get('userId') ?? '';
3092
- const email = url.searchParams.get('email') ?? undefined;
3093
- const displayName = url.searchParams.get('displayName') ?? undefined;
3094
- if (presentedState !== state) {
3095
- res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
3096
- res.end(errorHtml("State mismatch — the CLI didn't initiate this sign-in."));
3097
- return;
3098
- }
3099
- if (!idToken || !refreshToken || !userId) {
3100
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3101
- res.end(errorHtml('Missing token fields. Try <code>beehaven login</code> again.'));
3102
- return;
3103
- }
3104
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3105
- res.end(successHtml);
3106
- // Tear down the server after the response flushes so
3107
- // the browser has a chance to render the success
3108
- // page before the connection drops.
3109
- setTimeout(() => {
3110
- try {
3111
- server.close();
3112
- }
3113
- catch { }
3114
- }, 500);
3115
- resolve({ idToken, refreshToken, userId, email, displayName });
3116
- });
3117
- server.on('error', reject);
3118
- // 5-minute upper bound so a forgotten browser tab doesn't
3119
- // hang the terminal forever.
3120
- const timeout = setTimeout(() => {
3121
- try {
3122
- server.close();
3123
- }
3124
- catch { }
3125
- reject(new Error('Sign-in timed out after 5 minutes.'));
3126
- }, 5 * 60 * 1000);
3127
- server.on('close', () => clearTimeout(timeout));
3128
- server.listen(0, '127.0.0.1', () => {
3129
- const addr = server.address();
3130
- const port = addr && typeof addr === 'object' ? addr.port : 0;
3131
- const callbackUrl = `http://127.0.0.1:${port}/callback`;
3132
- const url = withUtm(`${authOrigin}/cli/auth?state=${state}&redirect=${encodeURIComponent(callbackUrl)}`);
3133
- console.log('');
3134
- console.log(` ${color.cyan('→')} Opening browser to ${authOrigin}/cli/auth …`);
3135
- console.log(` ${color.dim("If it doesn't open: ")}${color.dim(url)}`);
3136
- console.log(` ${color.dim('Listening on ' + callbackUrl + ' (5-min timeout).')}`);
3137
- console.log('');
3138
- try {
3139
- // Best-effort browser open. macOS `open` dispatches
3140
- // to the user's default browser; xdg-open on Linux;
3141
- // start on Windows.
3142
- const opener = process.platform === 'darwin'
3143
- ? 'open'
3144
- : process.platform === 'win32'
3145
- ? 'start'
3146
- : 'xdg-open';
3147
- const proc = childProcess.spawn(opener, [url], {
3148
- stdio: 'ignore',
3149
- detached: true,
3150
- });
3151
- proc.unref();
3152
- proc.on('error', (err) => {
3153
- // Surface — silent failure was the bug. If
3154
- // `open` isn't on PATH (e.g. unusual env),
3155
- // tell the user so they can paste manually.
3156
- console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
3157
- console.log(` Paste this URL into your browser instead:`);
3158
- console.log(` ${color.cyan(url)}`);
3159
- });
3160
- }
3161
- catch (err) {
3162
- console.log(` ${color.yellow('!')} Couldn't spawn opener: ${err?.message ?? err}`);
3163
- console.log(` Paste this URL into your browser instead:`);
3164
- console.log(` ${color.cyan(url)}`);
3165
- }
3166
- });
3167
- });
3168
- // Persist the captured tokens. Mirror the Mac app's
3169
- // installDaemon flow exactly: keychain stores the **idToken**
3170
- // under service='beehaven', account='daemon-token' (because
3171
- // that's what relay.ts:Relay.loadConfig reads on every
3172
- // daemon launch — line 116). The refresh token stays in
3173
- // config.json where relay.ts:refreshIdToken reads it from.
3174
- //
3175
- // Earlier this code was storing the refreshToken in the
3176
- // keychain — wrong slot. The daemon then tried to authenticate
3177
- // its WebSocket with the refresh token (treating it as an ID
3178
- // token), got 401, kicked off a refresh that succeeded but
3179
- // wrote the new idToken to config.json (not keychain), so on
3180
- // the next daemon restart the loop repeated. Storing idToken
3181
- // in keychain breaks the loop.
3182
- const { keychain } = await import('./keychain.js');
3183
- try {
3184
- keychain.setToken(captured.idToken);
3185
- }
3186
- catch { }
3008
+ // ─── Localhost loopback OAuth — ONE implementation (AWO-224) ───
3009
+ // `browser-login.ts` owns the loopback server, the /cli/auth URL, the
3010
+ // state check and where the tokens are stored. The daemon's interactive
3011
+ // fallback (setup-relay.ts) calls the same function; it used to carry its
3012
+ // own copy aimed at /auth/beehaven, a route the web app no longer serves.
3013
+ // What follows the sign-in — restarting the daemon, teaching the agents —
3014
+ // stays here, because the daemon fallback must not restart itself.
3015
+ let captured;
3187
3016
  try {
3188
- // Reuse saveCredentials() for the email/identity record.
3189
- // Tokens themselves go to keychain (above). Plain-file
3190
- // copy lives in config.json so the daemon can identify
3191
- // the user without unlocking the keychain on every read.
3192
- const { existsSync, mkdirSync, writeFileSync } = await import('fs');
3193
- const cfgDir = join(homedir(), '.beehaven');
3194
- if (!existsSync(cfgDir))
3195
- mkdirSync(cfgDir, { recursive: true, mode: 0o700 });
3196
- const cfgPath = join(cfgDir, 'config.json');
3197
- let existing = {};
3198
- if (existsSync(cfgPath)) {
3199
- try {
3200
- existing = JSON.parse(readFileSync(cfgPath, 'utf8'));
3201
- }
3202
- catch { }
3203
- }
3204
- /**
3205
- * ⚠⚠ SIGNING IN WRITES A DIAL TARGET. It used to write only credentials and lean on
3206
- * `...existing` to carry `endpoint` forward — which PRESERVES one and can never CREATE one.
3207
- * So on a config that had none (a fresh install before the Mac app's `install()` ran, or
3208
- * any config after `logout`, which deletes `endpoint` by name) login produced valid tokens
3209
- * with nothing to connect to, and the daemon reported "Token refresh failed" while the tray
3210
- * read "agent offline". A login that leaves the user unable to connect has not signed
3211
- * anyone in.
3212
- *
3213
- * ⚠ RESOLVED FROM THE CONFIG, NEVER A HARDCODED CONSTANT — `resolveRelaySelection` keys off
3214
- * whichever key is present and defaults to prod only when neither is. Pinning a literal
3215
- * here is the bug `setup-relay.ts` had: it sent every staging login to the prod relay.
3216
- *
3217
- * ⚠ BOTH KEYS, KEPT IN SYNC, exactly as `beehaven env` writes them — `endpoint` is what the
3218
- * relay client dials and `relayUrl` is the alias other surfaces read. Writing one and
3219
- * leaving the other stale is how they disagree about which environment you are signed into.
3220
- */
3221
- const { url: dialUrl } = resolveRelaySelection(existing);
3222
- const next = {
3223
- ...existing,
3224
- onboarded: true,
3225
- tier: existing.tier ?? 'connected',
3226
- userId: captured.userId,
3227
- email: captured.email ?? null,
3228
- displayName: captured.displayName ?? null,
3229
- tokenStorage: 'keychain',
3230
- refreshToken: captured.refreshToken,
3231
- token: captured.idToken,
3232
- endpoint: dialUrl,
3233
- relayUrl: dialUrl,
3234
- };
3235
- writeFileSync(cfgPath, JSON.stringify(next, null, 2), { mode: 0o600 });
3017
+ const { browserLogin } = await import('./browser-login.js');
3018
+ captured = await browserLogin();
3236
3019
  }
3237
3020
  catch (err) {
3238
- console.error(` ${color.red('✗')} Failed to write config: ${err?.message ?? err}`);
3021
+ console.error(` ${color.red('✗')} ${err?.message ?? err}`);
3239
3022
  process.exit(1);
3240
3023
  }
3241
3024
  console.log('');
@@ -3303,6 +3086,31 @@ async function run() {
3303
3086
  */
3304
3087
  console.log(` ${color.green('✓')} Connected — your new sign-in is live`);
3305
3088
  console.log(` Background service ${wasRunning ? 'restarted' : 'started'} (PID ${pid})`);
3089
+ /**
3090
+ * ⚠⚠ TEACH THE AGENTS AS PART OF SIGNING IN (PPO-190). "Install the CLI, log in, and your
3091
+ * agents handle the rest" needs the agents to KNOW about the CLI — the skills plus the
3092
+ * instruction block in ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md and a pointer in
3093
+ * ~/.claude/CLAUDE.md. That used to be a separate `beehaven skills install` the installer's
3094
+ * closing text asked for, and a step people skip is a step that did not happen.
3095
+ *
3096
+ * ⚠ Idempotent and non-fatal: unchanged files are not rewritten, symlinked files are left
3097
+ * alone, and a failure here never fails the sign-in. Opt out with `--no-agent-setup` or
3098
+ * BEEHAVEN_NO_AGENT_SETUP=1; undo with `beehaven skills uninstall`.
3099
+ */
3100
+ if (!values['no-agent-setup'] && process.env.BEEHAVEN_NO_AGENT_SETUP !== '1') {
3101
+ try {
3102
+ const { installAgentSkills } = await import('./commands/agent-skills.js');
3103
+ const { installAgentInstructions } = await import('./commands/agent-instructions.js');
3104
+ const sk = installAgentSkills();
3105
+ const ins = installAgentInstructions();
3106
+ const changed = [...sk.steps, ...ins.steps].filter((x) => /=(written|updated)$/.test(x)).length;
3107
+ console.log(` ${color.green('✓')} Your agents know about Clearly${changed ? '' : color.dim(' (already set up)')}`);
3108
+ console.log(color.dim(' Skills + instructions for Claude Code, Codex, Gemini and pi · undo: beehaven skills uninstall'));
3109
+ }
3110
+ catch (err) {
3111
+ console.log(color.dim(` Agent setup skipped: ${err?.message ?? err} — run: beehaven skills install`));
3112
+ }
3113
+ }
3306
3114
  console.log('');
3307
3115
  break;
3308
3116
  }
@@ -3358,7 +3166,7 @@ async function run() {
3358
3166
  const signupUrl = withUtm(
3359
3167
  // Was /avatar/install, which now 301s to the homepage — a signup CTA that lands on
3360
3168
  // the marketing page loses the person it was meant to convert. /signup is live.
3361
- process.env.BEEHAVEN_SIGNUP_URL ?? 'https://clearly.sh/signup');
3169
+ process.env.BEEHAVEN_SIGNUP_URL ?? 'https://www.clearly.sh/signup');
3362
3170
  console.log('');
3363
3171
  console.log(` Sign up at ${color.cyan(signupUrl)}.`);
3364
3172
  console.log(' Then run "beehaven login" to wire the CLI to your account.');
@@ -3897,7 +3705,13 @@ complete -F _beehaven_completions beehaven`);
3897
3705
  */
3898
3706
  if (sub === 'install' || sub === 'uninstall') {
3899
3707
  const { installAgentSkills, removeAgentSkills, AGENT_SKILL_DIRS } = await import('./commands/agent-skills.js');
3708
+ const { installAgentInstructions, removeAgentInstructions } = await import('./commands/agent-instructions.js');
3900
3709
  const r = sub === 'install' ? installAgentSkills() : removeAgentSkills();
3710
+ // ⚠ The instruction block travels with the skills (PPO-190) — a skill is only OFFERED;
3711
+ // the block is what makes an agent reach for `beehaven` at all.
3712
+ const ins = sub === 'install' ? installAgentInstructions() : removeAgentInstructions();
3713
+ r.steps.push(...ins.steps);
3714
+ r.warnings.push(...ins.warnings);
3901
3715
  const wrote = r.steps.filter((x) => /=(written|updated)$/.test(x)).length;
3902
3716
  const current = r.steps.filter((x) => x.endsWith('=current')).length;
3903
3717
  const removed = r.steps.filter((x) => x.endsWith('=removed')).length;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * agent-instructions — the "use the beehaven CLI" block in each agent's GLOBAL instruction file.
3
+ *
4
+ * ⚠⚠ THE MISSING HALF OF "install the CLI, log in, and your agents handle the rest" (PPO-190).
5
+ * `skills install` put SKILL.md files on disk, but a skill is only OFFERED to a model when its
6
+ * description matches. The instruction block is what makes an agent reach for `beehaven` at all
7
+ * when the user says "put this on my board". The Mac app has written it on every launch
8
+ * (`apps/electron/electron/agent-skills-installer.ts`); the CLI never did, so a CLI-only user's
9
+ * Codex and Gemini had skills they never used.
10
+ *
11
+ * ⚠⚠ BYTE-IDENTICAL TO THE MAC APP'S BLOCK, MARKER LINE INCLUDED. Both writers splice between the
12
+ * same BLOCK_START / BLOCK_END. If their text differed by one character, a machine with both would
13
+ * rewrite the file on every app launch and every CLI install, each undoing the other. One text
14
+ * means both converge and either uninstaller removes it. `agentInstructionParity.test.mjs` in the
15
+ * root repo pins the two copies together.
16
+ *
17
+ * ⚠ CLAUDE GETS THE SHORT POINTER — `~/.claude/CLAUDE.md` is prepended to every Claude Code
18
+ * session in every repository, so it carries a pointer to the skills, not a manual. Same rule as
19
+ * the Mac app; see its note on `AGENT_INSTRUCTION_FILES`.
20
+ *
21
+ * ⚠ ONLY OUR BLOCK IS EVER TOUCHED. Text around it is preserved byte for byte; a file with no
22
+ * block gets one appended; removal deletes the block and nothing else.
23
+ */
24
+ import fs from 'fs';
25
+ import os from 'os';
26
+ import path from 'path';
27
+ const MANAGED_MARKER = 'managed-by: clearly-mac-app';
28
+ export const BLOCK_START = '<!-- clearly:beehaven-cli:start -->';
29
+ export const BLOCK_END = '<!-- clearly:beehaven-cli:end -->';
30
+ /** Resolved at call time so tests can point HOME at a temp dir. */
31
+ export function agentInstructionFiles(home = os.homedir()) {
32
+ return [
33
+ { agent: 'codex', file: path.join(home, '.codex', 'AGENTS.md') },
34
+ { agent: 'gemini', file: path.join(home, '.gemini', 'GEMINI.md') },
35
+ { agent: 'claude', file: path.join(home, '.claude', 'CLAUDE.md') },
36
+ ];
37
+ }
38
+ /** The managed block appended to AGENTS.md / GEMINI.md. Keep identical to the Mac app's. */
39
+ export function instructionBlock() {
40
+ return [
41
+ BLOCK_START,
42
+ `<!-- ${MANAGED_MARKER}. Edits inside this block are overwritten on app launch; edit outside it. -->`,
43
+ '',
44
+ '## Clearly — drive it with the `beehaven` CLI',
45
+ '',
46
+ 'Clearly (clearly.sh) is a workspace — documents, projects, boards, tickets and a design',
47
+ 'canvas. Reach it from the shell with the `beehaven` CLI, NOT an HTTP API you construct.',
48
+ '',
49
+ '⚠ SIGN IN FIRST. Every call is attributed to an agent identity, and without one the CLI',
50
+ 'refuses. `login` MINTS the name if it does not exist yet, and binds this terminal:',
51
+ '',
52
+ '```sh',
53
+ 'beehaven agent login <your-name> # required — mints + binds, once per session',
54
+ 'beehaven connect home # target the personal workspace',
55
+ 'beehaven actions # list every action available there',
56
+ 'beehaven call <action> \'{...}\'',
57
+ '```',
58
+ '',
59
+ 'Read the full skills before driving it — they carry the exact verbs and node shapes:',
60
+ '',
61
+ '- `~/.<agent>/skills/clearly-agent/SKILL.md` — **read this first**: identity, sessions, your brief',
62
+ '- `~/.<agent>/skills/beehaven-cli/SKILL.md` — connect / call / sql / logs, the daemon',
63
+ '- `~/.<agent>/skills/clearly-workspace/SKILL.md` — audit what a workspace holds and who made it',
64
+ '- `~/.<agent>/skills/clearly-canvas/SKILL.md` — build + perceive a canvas, comm blocks, code diffs',
65
+ '- `~/.<agent>/skills/design-craft/SKILL.md` — **load before any visual work**: grid, type, colour, the studio pass',
66
+ '- `~/.<agent>/skills/layout-systems/SKILL.md` — grids and systems across many artboards',
67
+ '- `~/.<agent>/skills/pair-on-canvas/SKILL.md` — drive a coding task from a spec on the canvas',
68
+ '- `~/.<agent>/skills/codebase-map/SKILL.md` — draw a repo’s architecture on the canvas',
69
+ '',
70
+ 'If a command reports the daemon is unreachable, `beehaven login` re-auths and',
71
+ '`beehaven start --headless` starts a daemon when the Mac app is not running.',
72
+ BLOCK_END,
73
+ ].join('\n');
74
+ }
75
+ /** The short form, for `~/.claude/CLAUDE.md`. Keep identical to the Mac app's `claudeBlock()`. */
76
+ export function claudeBlock() {
77
+ return [
78
+ BLOCK_START,
79
+ `<!-- ${MANAGED_MARKER}. Edits inside this block are overwritten on app launch; edit outside it. -->`,
80
+ '',
81
+ '**Clearly workspace** — drive it from the shell with the `beehaven` CLI (documents, projects,',
82
+ 'boards, tickets, canvas). Sign in once per session first: `beehaven agent login <name>` —',
83
+ 'it mints the identity if new. Full detail is in the skills, `clearly-agent` first.',
84
+ BLOCK_END,
85
+ ].join('\n');
86
+ }
87
+ /** Insert or replace our block, leaving everything around it untouched. Pure. */
88
+ export function upsertBlock(existing, block) {
89
+ const start = existing.indexOf(BLOCK_START);
90
+ const end = existing.indexOf(BLOCK_END);
91
+ if (start !== -1 && end !== -1 && end > start) {
92
+ return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
93
+ }
94
+ return existing.trim() ? `${existing.trimEnd()}\n\n${block}\n` : `${block}\n`;
95
+ }
96
+ /** Remove our block (and the blank line it was appended after). Pure. */
97
+ export function stripBlock(existing) {
98
+ const start = existing.indexOf(BLOCK_START);
99
+ const end = existing.indexOf(BLOCK_END);
100
+ if (start === -1 || end === -1 || end < start)
101
+ return existing;
102
+ const before = existing.slice(0, start).replace(/\n{1,2}$/, '\n');
103
+ const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, '');
104
+ const out = (before + after);
105
+ return out.trim() ? out : '';
106
+ }
107
+ /**
108
+ * Write the block into every agent's global instruction file. Idempotent: an unchanged file is
109
+ * reported `=current` and not rewritten.
110
+ *
111
+ * ⚠ A SYMLINKED INSTRUCTION FILE IS LEFT ALONE. People keep dotfiles in a repo and link them in;
112
+ * writing through the link would silently edit their checkout. Same rule as the skills installer.
113
+ */
114
+ export function installAgentInstructions(home = os.homedir()) {
115
+ const steps = [];
116
+ const warnings = [];
117
+ for (const { agent, file } of agentInstructionFiles(home)) {
118
+ try {
119
+ /**
120
+ * ⚠ ONLY FOR AGENTS THAT ARE INSTALLED. Creating `~/.gemini/GEMINI.md` for someone without
121
+ * Gemini invents an agent: the skills installer keys on that same directory existing, so
122
+ * the NEXT run would install skills for it too. Same rule as `installAgentSkills`.
123
+ */
124
+ if (!fs.existsSync(path.dirname(file))) {
125
+ steps.push(`${agent}/instructions=agent-absent`);
126
+ continue;
127
+ }
128
+ if (isSymlink(file)) {
129
+ steps.push(`${agent}/instructions=symlink-skipped`);
130
+ continue;
131
+ }
132
+ let existing = '';
133
+ try {
134
+ existing = fs.readFileSync(file, 'utf8');
135
+ }
136
+ catch { /* first run */ }
137
+ const next = upsertBlock(existing, agent === 'claude' ? claudeBlock() : instructionBlock());
138
+ if (next === existing) {
139
+ steps.push(`${agent}/instructions=current`);
140
+ continue;
141
+ }
142
+ fs.writeFileSync(file, next, { mode: 0o644 });
143
+ steps.push(`${agent}/instructions=${existing ? 'updated' : 'written'}`);
144
+ }
145
+ catch (e) {
146
+ warnings.push(`${agent}/instructions: ${e?.message ?? String(e)}`);
147
+ }
148
+ }
149
+ return { steps, warnings };
150
+ }
151
+ /** Remove only our block. A file that becomes empty is deleted only if we created all of it. */
152
+ export function removeAgentInstructions(home = os.homedir()) {
153
+ const steps = [];
154
+ const warnings = [];
155
+ for (const { agent, file } of agentInstructionFiles(home)) {
156
+ try {
157
+ if (!fs.existsSync(file) || isSymlink(file))
158
+ continue;
159
+ const existing = fs.readFileSync(file, 'utf8');
160
+ const next = stripBlock(existing);
161
+ if (next === existing)
162
+ continue;
163
+ if (next)
164
+ fs.writeFileSync(file, next);
165
+ else
166
+ fs.unlinkSync(file);
167
+ steps.push(`${agent}/instructions=removed`);
168
+ }
169
+ catch (e) {
170
+ warnings.push(`${agent}/instructions: ${e?.message ?? String(e)}`);
171
+ }
172
+ }
173
+ return { steps, warnings };
174
+ }
175
+ function isSymlink(file) {
176
+ try {
177
+ return fs.lstatSync(file).isSymbolicLink();
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }