@clearlysh/beehaven 0.8.30 → 0.8.32

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/dashboard.js CHANGED
@@ -17,6 +17,59 @@ import { readFileSync, existsSync } from 'node:fs';
17
17
  import { join, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { WebSocketServer } from 'ws';
20
+ // ── Who may talk to this server (CAN2-64) ───────────────────────────────────
21
+ /**
22
+ * ⚠⚠ A LOOPBACK PORT IS NOT A PRIVATE CHANNEL. Every web page the person opens can dial
23
+ * `ws://127.0.0.1:9800` — WebSockets ignore CORS — and so can every program on the machine,
24
+ * another Windows or macOS user's included. `/apps/ws` used to hand EVERY connection a
25
+ * developer-scope IPC client with no check at all (a comment in ipc-server.ts claimed a session
26
+ * cookie and an origin check that did not exist), so any website could ask the daemon for the
27
+ * signed-in Firebase token, run `sql`/`shell`, or install a package. Proven against a live
28
+ * daemon on 2026-10-13 with `Origin: https://evil.example`. The rules now:
29
+ *
30
+ * 1. The Host header must name this loopback server — a DNS-rebound hostname is refused.
31
+ * 2. `/apps/ws`: a PAGE must come from this server (an installed hive's frontend) or be a
32
+ * `file://` page (the Mac app loads the avatar UI with `loadFile`; a web page cannot claim
33
+ * `file://`), and it gets CONSUMER scope. A PROGRAM (no Origin) starts unauthenticated and
34
+ * must present the daemon's IPC token with `{type:'auth'}` — the pipe's own boundary. Any
35
+ * other origin, `null` included (sandboxed iframes send it), is refused at the upgrade.
36
+ * 3. JSON routes answer CORS only to loopback origins (a dev server on localhost). Browser
37
+ * building modules stay `*`: clearly.sh imports that public code cross-origin.
38
+ */
39
+ export function isLoopbackHostHeader(host, port) {
40
+ const h = (host ?? '').toLowerCase();
41
+ return h === `127.0.0.1:${port}` || h === `localhost:${port}` || h === `[::1]:${port}`;
42
+ }
43
+ /** `http://localhost:N`, `http://127.0.0.1:N` or `http://[::1]:N` — a page served on this machine. */
44
+ export function isLoopbackOrigin(origin) {
45
+ if (!origin)
46
+ return false;
47
+ try {
48
+ const u = new URL(origin);
49
+ return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** A page served by THIS server. */
56
+ export function isSelfOrigin(origin, port) {
57
+ return (origin === `http://127.0.0.1:${port}` ||
58
+ origin === `http://localhost:${port}` ||
59
+ origin === `http://[::1]:${port}`);
60
+ }
61
+ /** How a `/apps/ws` connection starts: `consumer` scope, unauthenticated (`token`), or refused (null). */
62
+ export function appBridgeTrust(origin, port) {
63
+ if (!origin)
64
+ return 'token';
65
+ if (origin === 'file://' || isSelfOrigin(origin, port))
66
+ return 'consumer';
67
+ return null;
68
+ }
69
+ /** `/ws` streams events and accepts console logs: loopback or file pages, or programs. */
70
+ export function metricsSocketAllowed(origin) {
71
+ return !origin || origin === 'file://' || isLoopbackOrigin(origin);
72
+ }
20
73
  // ── Fallback HTML ───────────────────────────────────────────────────────────
21
74
  const FALLBACK_HTML = `<!DOCTYPE html>
22
75
  <html lang="en">
@@ -150,11 +203,28 @@ export class Dashboard {
150
203
  // first WSS's listener — both listeners fire on every upgrade event.
151
204
  // Instead we accept all upgrades and route by req.url in the
152
205
  // connection handler.
153
- this._wss = new WebSocketServer({ server: this._server });
206
+ //
207
+ // Every upgrade is judged BEFORE it is accepted (see "Who may talk to this server").
208
+ this._wss = new WebSocketServer({
209
+ server: this._server,
210
+ verifyClient: (info, done) => {
211
+ const req = info.req;
212
+ const url = req.url ?? '';
213
+ const origin = req.headers.origin;
214
+ if (!isLoopbackHostHeader(req.headers.host, this.port))
215
+ return done(false, 403, 'Forbidden');
216
+ if (url.startsWith('/apps/ws')) {
217
+ return appBridgeTrust(origin, this.port) ? done(true) : done(false, 403, 'Forbidden');
218
+ }
219
+ if (url.startsWith('/ws'))
220
+ return metricsSocketAllowed(origin) ? done(true) : done(false, 403, 'Forbidden');
221
+ return done(false, 404, 'Not Found');
222
+ },
223
+ });
154
224
  this._wss.on('connection', (ws, req) => {
155
225
  const url = req.url ?? '';
156
226
  if (url.startsWith('/apps/ws')) {
157
- this._handleAppWsConnection(ws);
227
+ this._handleAppWsConnection(ws, appBridgeTrust(req.headers.origin, this.port));
158
228
  }
159
229
  else if (url.startsWith('/ws')) {
160
230
  this._handleWSConnection(ws, req);
@@ -255,8 +325,10 @@ export class Dashboard {
255
325
  get url() {
256
326
  return `http://${this._config.host}:${this._config.port}`;
257
327
  }
328
+ /** The port actually bound (config 0 picks one), which the Host and Origin checks compare to. */
258
329
  get port() {
259
- return this._config.port;
330
+ const addr = this._server?.address();
331
+ return addr && typeof addr === 'object' ? addr.port : this._config.port;
260
332
  }
261
333
  get running() {
262
334
  return this._server !== null;
@@ -268,6 +340,30 @@ export class Dashboard {
268
340
  _handleHTTP(req, res) {
269
341
  const url = req.url ?? '/';
270
342
  const method = req.method ?? 'GET';
343
+ const origin = req.headers.origin;
344
+ // A DNS-rebound name pointing at 127.0.0.1 carries its own Host — refuse it on every route.
345
+ if (!isLoopbackHostHeader(req.headers.host, this.port)) {
346
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
347
+ res.end('Forbidden');
348
+ return;
349
+ }
350
+ /**
351
+ * ⚠ ONE CHOKEPOINT FOR CORS. The handlers below were written to answer `*`; every response now
352
+ * passes through here, which keeps `*` only for browser building modules (public code that
353
+ * clearly.sh imports cross-origin) and otherwise reflects a loopback origin or sends none.
354
+ */
355
+ const publicModule = /^\/apps\/[^/]+\/buildings\/[^/]+\.js$/.test(url);
356
+ const allowOrigin = publicModule ? '*' : isLoopbackOrigin(origin) ? origin : null;
357
+ const writeHead = res.writeHead.bind(res);
358
+ res.writeHead = (status, ...rest) => {
359
+ const headers = rest.find((r) => r && typeof r === 'object');
360
+ if (headers && 'Access-Control-Allow-Origin' in headers) {
361
+ delete headers['Access-Control-Allow-Origin'];
362
+ if (allowOrigin)
363
+ Object.assign(headers, { 'Access-Control-Allow-Origin': allowOrigin, Vary: 'Origin' });
364
+ }
365
+ return writeHead(status, ...rest);
366
+ };
271
367
  // Handle CORS preflight
272
368
  if (method === 'OPTIONS') {
273
369
  res.writeHead(204, {
@@ -280,8 +376,14 @@ export class Dashboard {
280
376
  return;
281
377
  }
282
378
  try {
283
- // Explorer API proxy — forward /api/explorer/* to the Worker
379
+ // Explorer API proxy — forward /api/explorer/* to the Worker. It adds the admin secret, so
380
+ // only this server's own explorer page (or a program, which sends no Origin) may use it.
284
381
  if (url.startsWith('/api/explorer')) {
382
+ if (origin && !isSelfOrigin(origin, this.port)) {
383
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
384
+ res.end('Forbidden');
385
+ return;
386
+ }
285
387
  this._proxyExplorer(req, res, url);
286
388
  return;
287
389
  }
@@ -350,6 +452,11 @@ export class Dashboard {
350
452
  }
351
453
  }
352
454
  if (method === 'POST' && url === '/api/console') {
455
+ if (!metricsSocketAllowed(origin)) {
456
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
457
+ res.end('Forbidden');
458
+ return;
459
+ }
353
460
  this._handleConsolePost(req, res);
354
461
  return;
355
462
  }
@@ -702,7 +809,11 @@ export class Dashboard {
702
809
  : { installed: [], available: [] };
703
810
  this._serveJSON(res, payload);
704
811
  }
705
- _handleAppWsConnection(ws) {
812
+ _handleAppWsConnection(ws, trust) {
813
+ if (!trust) {
814
+ ws.close(1008, 'forbidden');
815
+ return;
816
+ }
706
817
  if (!this._appBridge) {
707
818
  ws.close(1011, 'app bridge not configured');
708
819
  return;
@@ -720,7 +831,8 @@ export class Dashboard {
720
831
  // Subscribe broadly by default — browser UIs usually want to see
721
832
  // everything (pipeline:*, broadcast:*, etc.). Clients can tighten this
722
833
  // by sending their own { type: 'subscribe', patterns: [...] } message.
723
- const virtId = bridge.createVirtualClient(send, ['*']);
834
+ // Consumer scope at most: a page never gets developer verbs (get-token, shell, sql, installs).
835
+ const virtId = bridge.createVirtualClient(send, ['*'], trust === 'consumer' ? 'consumer' : null);
724
836
  ws.on('message', (raw) => {
725
837
  let msg;
726
838
  try {
@@ -602,20 +602,23 @@ export class IpcServer extends EventEmitter {
602
602
  // IPC bus so the same pipeline/app-rpc handlers serve both Claude and
603
603
  // the human UI.
604
604
  /** Register a virtual client and return its id. */
605
- createVirtualClient(virtualSend, initialSubscriptions = ['*']) {
605
+ createVirtualClient(virtualSend, initialSubscriptions = ['*'], scope = null) {
606
606
  const id = `virt_${++this.virtualClientCounter}`;
607
+ /**
608
+ * ⚠⚠ A VIRTUAL CLIENT IS ONLY AS TRUSTED AS WHOEVER IS ON THE OTHER END OF IT (CAN2-64).
609
+ * This used to be `authenticated: true, scope: 'developer'` for everyone, justified by "the
610
+ * dashboard bridge enforces its own auth (session cookie, origin check)". It enforced none,
611
+ * so any web page that opened ws://127.0.0.1:9800/apps/ws could call get-token. The bridge
612
+ * now judges the connection (dashboard.ts `appBridgeTrust`) and passes 'consumer' or null;
613
+ * a null client must authenticate with the IPC token, exactly like a socket client.
614
+ */
607
615
  const client = {
608
616
  id,
609
617
  virtualSend,
610
618
  subscriptions: initialSubscriptions,
611
619
  buffer: '',
612
- // Virtual clients are in-process — they're already inside the trust
613
- // boundary. The dashboard /apps/ws bridge enforces its own auth
614
- // (session cookie, origin check) before creating the virtual client,
615
- // so we hand them developer scope to mirror what a logged-in tray
616
- // user can already do.
617
- authenticated: true,
618
- scope: 'developer',
620
+ authenticated: scope !== null,
621
+ scope,
619
622
  };
620
623
  this.clients.set(id, client);
621
624
  // Mimic the on-connect status frame real clients receive so the bridge
@@ -0,0 +1,138 @@
1
+ /**
2
+ * npm-update — updating a CLI that was installed with `npm install -g` (CAN2-57).
3
+ *
4
+ * ⚠⚠ npm IS THE ONLY WINDOWS INSTALL, AND IT NEVER UPDATED. The daemon's hourly updater re-runs the
5
+ * bash `install.sh`, so it served only `~/.beehaven/cli` installs; `beehaven update` refused npm
6
+ * installs and printed the npm command instead. A Windows user stayed on whatever version they
7
+ * first installed until they noticed the notice. Both paths now call this module.
8
+ *
9
+ * ⚠ IT UPDATES THE COPY THAT IS RUNNING, OR NOTHING. The npm that sits beside the running Node is
10
+ * asked for its global root; if this package is not the one installed there (an npx run, a second
11
+ * Node, a copied folder), installing would create a SECOND copy that shadows or is shadowed by
12
+ * this one — the failure `self-update.ts` documents for curl installs. So it refuses and says why.
13
+ *
14
+ * ⚠ A GLOBAL PREFIX THE USER CANNOT WRITE IS NOT "TRY sudo". Windows (`%AppData%\npm`) and
15
+ * Homebrew/nvm prefixes are user-writable; a system `/usr/local` usually is not, and an unattended
16
+ * daemon must never escalate. Not writable → the one-line manual command, nothing else.
17
+ *
18
+ * ⚠ THE EXACT VERSION, NOT `@latest`. The caller passes the version from the release manifest
19
+ * (`latest.json`, stable only), so the channel is decided by our publish script, never by a dist
20
+ * tag someone moved by hand.
21
+ */
22
+ import { execFile, execFileSync } from 'child_process';
23
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { dirname, join, resolve } from 'path';
26
+ export const PACKAGE = '@clearlysh/beehaven';
27
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
28
+ /** The npm that belongs to the Node running this process, else whatever `npm` is on PATH. */
29
+ export function npmCommand(platform = process.platform, execPath = process.execPath) {
30
+ const local = join(dirname(execPath), platform === 'win32' ? 'npm.cmd' : 'npm');
31
+ return existsSync(local) ? local : platform === 'win32' ? 'npm.cmd' : 'npm';
32
+ }
33
+ /** Windows `.cmd` shims cannot be spawned without a shell (Node refuses with EINVAL since 18.20/20.12). */
34
+ const needsShell = (cmd) => /\.cmd$/i.test(cmd);
35
+ const quoteForShell = (s) => (/[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
36
+ /** Run npm synchronously; returns stdout. Never inside the package directory (Windows rename locks). */
37
+ function npmSync(npm, args, timeoutMs) {
38
+ const shell = needsShell(npm);
39
+ return execFileSync(shell ? quoteForShell(npm) : npm, args, {
40
+ encoding: 'utf8',
41
+ cwd: homedir(),
42
+ timeout: timeoutMs,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ shell,
45
+ windowsHide: true,
46
+ });
47
+ }
48
+ /**
49
+ * Can this install update itself with npm? `pkgRoot` is the running package's root (the folder
50
+ * holding its package.json).
51
+ */
52
+ export function npmUpdatePlan(pkgRoot, platform = process.platform) {
53
+ const npm = npmCommand(platform);
54
+ let globalRoot;
55
+ try {
56
+ globalRoot = npmSync(npm, ['root', '-g'], 30_000).trim();
57
+ }
58
+ catch (e) {
59
+ return { ok: false, npm, reason: `npm is not available (${String(e?.code ?? e?.message ?? e).slice(0, 80)})` };
60
+ }
61
+ const real = (p) => { try {
62
+ return realpathSync(p);
63
+ }
64
+ catch {
65
+ return resolve(p);
66
+ } };
67
+ const installed = join(globalRoot, ...PACKAGE.split('/'));
68
+ if (real(installed).toLowerCase() !== real(pkgRoot).toLowerCase()) {
69
+ return { ok: false, npm, globalRoot, reason: `this copy (${pkgRoot}) is not npm's global install (${installed})` };
70
+ }
71
+ try {
72
+ accessSync(globalRoot, constants.W_OK);
73
+ accessSync(installed, constants.W_OK);
74
+ }
75
+ catch {
76
+ return { ok: false, npm, globalRoot, reason: `npm's global folder is not writable by you (${globalRoot})` };
77
+ }
78
+ return { ok: true, npm, globalRoot };
79
+ }
80
+ /** The version npm's global install of this package reports, read from disk. */
81
+ export function installedNpmVersion(globalRoot) {
82
+ try {
83
+ return JSON.parse(readFileSync(join(globalRoot, ...PACKAGE.split('/'), 'package.json'), 'utf8')).version ?? null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** The newest published version, from npm (for `beehaven update`; the daemon uses the release manifest). */
90
+ export function latestNpmVersion(npm) {
91
+ try {
92
+ const v = npmSync(npm, ['view', PACKAGE, 'version', '--registry=https://registry.npmjs.org'], 30_000).trim();
93
+ return VERSION_RE.test(v) ? v : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** The arguments that install exactly `version` globally — exported so the shape is pinned. */
100
+ export function npmInstallArgs(version) {
101
+ if (!VERSION_RE.test(version))
102
+ throw new Error(`not a release version: ${version}`);
103
+ return ['install', '-g', `${PACKAGE}@${version}`, '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'];
104
+ }
105
+ /** Install `version` (blocking — the CLI's `update`). Verifies what landed on disk. */
106
+ export function installNpmVersionSync(plan, version) {
107
+ if (!plan.ok || !plan.globalRoot)
108
+ return { ok: false, message: plan.reason ?? 'npm update not possible here' };
109
+ try {
110
+ npmSync(plan.npm, npmInstallArgs(version), 5 * 60_000);
111
+ }
112
+ catch (e) {
113
+ const detail = String(e?.stderr || e?.message || e).trim().split('\n').slice(-3).join(' ').slice(0, 240);
114
+ return { ok: false, message: `npm install failed: ${detail}` };
115
+ }
116
+ const got = installedNpmVersion(plan.globalRoot);
117
+ return got === version
118
+ ? { ok: true, message: `Updated to ${version}.` }
119
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` };
120
+ }
121
+ /** Install `version` without blocking the event loop (the daemon's hourly updater). */
122
+ export function installNpmVersion(plan, version) {
123
+ return new Promise((resolveP) => {
124
+ if (!plan.ok || !plan.globalRoot)
125
+ return resolveP({ ok: false, message: plan.reason ?? 'npm update not possible here' });
126
+ const shell = needsShell(plan.npm);
127
+ execFile(shell ? quoteForShell(plan.npm) : plan.npm, npmInstallArgs(version), {
128
+ cwd: homedir(), timeout: 5 * 60_000, shell, windowsHide: true,
129
+ }, (err, _out, stderr) => {
130
+ if (err)
131
+ return resolveP({ ok: false, message: `npm install failed: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ').slice(0, 240)}` });
132
+ const got = installedNpmVersion(plan.globalRoot);
133
+ resolveP(got === version
134
+ ? { ok: true, message: `Updated to ${version}.` }
135
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` });
136
+ });
137
+ });
138
+ }
@@ -60,6 +60,7 @@ export const VERB_SURFACE = {
60
60
  logout: { cls: 'STRUCTURAL', why: 'clears local credentials' },
61
61
  signup: { cls: 'STRUCTURAL', why: 'opens a browser' },
62
62
  handoff: { cls: 'STRUCTURAL', why: 'posts a local file to the anonymous door; no account, no workspace' },
63
+ 'agents-md': { cls: 'STRUCTURAL', why: "writes the Clearly block into this repo's AGENTS.md; no workspace" },
63
64
  whoami: { cls: 'STRUCTURAL', why: 'reads local credentials' },
64
65
  start: { cls: 'STRUCTURAL', why: 'spawns the daemon `call` needs' },
65
66
  stop: { cls: 'STRUCTURAL', why: 'daemon lifecycle' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearlysh/beehaven",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,6 +40,12 @@ const SOURCES = {
40
40
  'codebase-map': 'apps/mcp-server/plugin/skills/codebase-map/SKILL.md',
41
41
  'clearly-workspace': 'apps/mcp-server/plugin/skills/clearly-workspace/SKILL.md',
42
42
  'clearly-agent': 'apps/mcp-server/plugin/skills/clearly-agent/SKILL.md',
43
+ // CAN2-57 — the rest of the plugin's skills: one set across plugin, CLI and Mac app.
44
+ 'visualize': 'apps/mcp-server/plugin/skills/visualize/SKILL.md',
45
+ 'sticker-pack': 'apps/mcp-server/plugin/skills/sticker-pack/SKILL.md',
46
+ 'clearly-docs': 'apps/mcp-server/plugin/skills/clearly-docs/SKILL.md',
47
+ 'clearly-init': 'apps/mcp-server/plugin/skills/clearly-init/SKILL.md',
48
+ 'clearly-workflows': 'apps/mcp-server/plugin/skills/clearly-workflows/SKILL.md',
43
49
  };
44
50
 
45
51
  let missing = 0;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-doodle-space
3
- description: Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
3
+ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
4
4
  ---
5
5
 
6
6
  # Agent Soul + Doodle Space
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-home
3
- description: Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
3
+ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
4
4
  ---
5
5
 
6
6
  # Agent Home memory apartment
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: brand-identity
3
3
  description: >-
4
+ Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead.
4
5
  Design the requested logo or brand identity on a Clearly canvas —
5
6
  brief, mark, wordmark, lockups and clear space, palette, type system,
6
7
  applications, and a spec board someone else can build from. Use when asked
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clearly-agent
3
- description: Work in a Clearly workspace AS an agent — sign in with an identity, resume a session, read your brief (what changed since you were last here, your recents, your unfiled drafts), and understand whose work you are creating. TRIGGER on the first `beehaven` call of any session, on "Not an agent" / "agent login" refusals, and when the user asks "who am I acting as", "what was I doing", "what have I made", "why can't the agent see this", "why is my work invisible", or "run this as a different agent". Read this BEFORE clearly-canvas or clearly-workspace — both need an identity and neither will work without one.
3
+ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Work in a Clearly workspace AS an agent — sign in with an identity, resume a session, read your brief (what changed since you were last here, your recents, your unfiled drafts), and understand whose work you are creating. TRIGGER on the first `beehaven` call of any session, on "Not an agent" / "agent login" refusals, and when the user asks "who am I acting as", "what was I doing", "what have I made", "why can't the agent see this", "why is my work invisible", or "run this as a different agent". Read this BEFORE clearly-canvas or clearly-workspace — both need an identity and neither will work without one.
4
4
  ---
5
5
 
6
6
  # clearly-agent — who you are, and what that makes your work
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: clearly-canvas
3
3
  description: >-
4
+ Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead.
4
5
  Design and revise editable compositions on a Clearly canvas using the beehaven
5
6
  CLI: posters, campaigns, decks, page mockups, diagrams, spatial reviews, and
6
7
  project plans built from LIVE workspace objects (boards, tickets, documents,
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: clearly-docs
3
+ description: >-
4
+ Read, search, edit and recover a company's documents in a Clearly workspace.
5
+ Use the seven-tool MCP filesystem for ordinary work; use the `beehaven` CLI
6
+ only for document history, diffs, and restore. Load this whenever the answer
7
+ depends on what the company has written down, or when you are about to write
8
+ something down for it. Triggers: "what do our docs say", "find the doc about
9
+ X", "update the spec", "write this up", "add it to the docs", "has this
10
+ changed", "what did they edit", "search the wiki", "company knowledge", "our
11
+ documentation", "the PRD", "the runbook".
12
+ ---
13
+
14
+ > **Tool names below are written UNPREFIXED** (`clearly_read`). Your runtime may expose them
15
+ > with a server prefix — e.g. `mcp__plugin_clearly_clearly__clearly_read`. **Match by suffix,
16
+ > not by exact name**: a skill written against the bare name resolves to nothing otherwise, and
17
+ > the failure looks like "the tool doesn't exist" rather than "the name is decorated".
18
+ >
19
+ > **If no such tool is callable at all**, the Clearly MCP server isn't authorised in this
20
+ > session — these skills still LIST when it isn't, so you find out by firing a dead call.
21
+ > Authorise it (`/mcp`, or `claude mcp`).
22
+
23
+ ## What this is
24
+
25
+ The workspace is a **filesystem**, not a chat surface with a search box.
26
+
27
+ ```
28
+ ~/ every document
29
+ ~/clearly.md what is settled for the whole workspace
30
+ ~/<Folder>/ one directory per project
31
+ ~/<Folder>/clearly.md what is settled for that folder
32
+ ~/.archive/ put away — hidden, readable, read-only, still searched
33
+ ```
34
+
35
+ Folders are projects. Documents are `.md` files named `<KEY> <Title>.md` — e.g.
36
+ `CLR-42 Pricing.md`. The key is an address: `cat ~/CLR-42.md` works from anywhere, and so does
37
+ the full name and the bare title.
38
+
39
+ ## The loop
40
+
41
+ **Find it, then open it.** `clearly_glob` answers "what is it called", `clearly_grep` answers
42
+ "where is this mentioned", and `clearly_read` opens what either one returned.
43
+
44
+ ```
45
+ clearly_glob { pattern: "~/**/*.md" }
46
+ clearly_grep { pattern: "pricing", limit: 5 }
47
+ clearly_read { target: "~/Prepress/CLR-42 Pricing.md" }
48
+ ```
49
+
50
+ ⚠ **`clearly_grep` takes a REGEX, unparsed** — `|`, `(`, `$` and quotes mean what they mean in a
51
+ regular expression. Nothing shells out, so nothing eats them as syntax first.
52
+
53
+ ⚠ **Archived documents are excluded from both.** When any matched, the result carries
54
+ `archivedHidden` saying how many — mention that rather than reporting a clean miss. Search the
55
+ archive by naming it: `clearly_grep { pattern: …, path: "~/.archive" }`.
56
+
57
+ **Structural questions — a date range, a null body, counts — have no keyword to search for**, so
58
+ they go to SQL over the workspace's own tables. That is a CLI job, not an MCP tool:
59
+
60
+ ```
61
+ beehaven call bash '{"command":"sql SELECT title, updated_at FROM notes WHERE project_id IS NULL LIMIT 20"}'
62
+ ```
63
+
64
+ Read-only, one statement, credential columns redacted.
65
+
66
+ ## ⚠⚠ Read before you write, and check what changed
67
+
68
+ This is the part that makes documentation stay true, and it is the part agents skip.
69
+
70
+ The MCP read/edit/replace tools cover the current body. Document history is a separate CLI surface:
71
+
72
+ - **`beehaven call document-status '{"limit":20}'`** lists recent changes. `mine: true` is the
73
+ certain signal that the active agent made the latest change. `by: "unknown"` means the log has
74
+ no author; it may be a browser edit or an older unstamped agent write, so do not claim a person
75
+ made it.
76
+ - **`beehaven call document-diff '{"id":"<documentId>"}'`** follows up with added and removed
77
+ lines. Pass a `versionId` from status to compare against a specific checkpoint.
78
+
79
+ Run status when you resume a conversation, and whenever they say something implying the document
80
+ has moved — *"I rewrote that"*, *"take another look"*.
81
+
82
+ ⚠ **Their edit wins, always.** If a document now disagrees with something you concluded
83
+ earlier, your conclusion is the stale one. You are keeping their documents, not defending your
84
+ own record. Never quietly restore what they took out.
85
+
86
+ ## Editing
87
+
88
+ **`clearly_edit` (find/replace) is the default. `clearly_replace` REPLACES the whole document.**
89
+
90
+ To change one line of a 12,000-character document, `replace` makes you reproduce all 12,000 — and
91
+ anything you fail to retype is gone with no error. It is also a lost-update machine: it writes
92
+ whatever you last read, overwriting anything the human changed in between.
93
+
94
+ - `clearly_edit` requires the `find` text to match **exactly once**. Ambiguity is REFUSED with a
95
+ count, never guessed — an agent that edits the first of three matches has changed something
96
+ nobody looked at.
97
+ - Several independent document edits can be sent in one tool call with `batch`, but each batch
98
+ row is still one exact `{ target, find, replace, all? }` operation. There is no `multi_edit`
99
+ field and the batch is not transactional; inspect the per-item results and retry only failures.
100
+ - `clearly_replace` does not accept `expected_content` or a commit-message field. If you must replace
101
+ a whole existing document, re-read it immediately before writing and verify the returned body.
102
+ Prefer `clearly_edit` whenever a precise passage change can express the job.
103
+
104
+ ## Comments and @mentions — say it beside the work, not instead of it
105
+
106
+ A comment is ABOUT something; an edit changes it. When you have a question or a note for a person,
107
+ leave it where they will see it, on the thing itself:
108
+
109
+ ```
110
+ clearly_comment { target: "CLR-42", content: "Is this still the Q3 number? @sam" }
111
+ clearly_comment { target: "<sheetId>", content: "Double-counts refunds", fields: { cell: "Plan!D12" } }
112
+ clearly_comment { target: "<deckId>", content: "Tighten this headline", fields: { slide: 3 } }
113
+ clearly_read { type: "comment", target: "SS-41" } # the thread, on any item
114
+ clearly_read { type: "comment", target: "@me" } # where YOU were @mentioned
115
+ clearly_edit { type: "comment", target: "<mentionId>", changes: { acknowledged: true } }
116
+ ```
117
+
118
+ `target` is the item — a document, sheet, deck, ticket, board, thought or canvas, by id, key or exact
119
+ title. Anchor with `fields.quote` (document), `slide` (deck, required), `cell` (sheet), `column`
120
+ (board) or `pinId` / `x`,`y` (canvas); an anchor the item cannot carry is refused, never dropped.
121
+
122
+ `@handle` mentions a person (notified) or an agent (queued in its inbox). Get real handles with
123
+ `clearly_read { type: "comment", target, includeMentionables: true }`. The write's result lists
124
+ `mentions`, and `unmatchedMentions` for any handle that reached **no one** — say so, do not assume.
125
+ Acknowledge a mention once you have acted on it, or it comes back in every session.
126
+
127
+ ## `clearly.md` — what is settled
128
+
129
+ Not a document like the others. It holds what this place IS (the work, the constraints, the
130
+ vocabulary, the decisions that stopped moving) and **how this person wants to be worked with** —
131
+ length, tone, format, and what irritates them, which is said once in passing and worth more than
132
+ anything they praise.
133
+
134
+ Fold a conclusion into it once it has stopped moving. Reach for `edit`, never `replace`.
135
+
136
+ ## Report what MOVED
137
+
138
+ *"You added a paragraph about the 12-hour window to Pricing and removed the line about three
139
+ credits"* is a report. *"I've updated the note"* is not — it costs a turn and says nothing they
140
+ could not have assumed.
141
+
142
+ ## What not to do
143
+
144
+ - **Do not tidy unasked.** A document that looks redundant or superseded is still theirs. If two
145
+ say the same thing, say so and let them choose.
146
+ - **`delete` archives, it does not erase** — the document moves to `~/.archive/`, stays readable
147
+ and comes back. Report it as **archived**; "deleted" makes someone believe something is gone
148
+ that is not.
149
+ - **Do not invent a media line.** `![caption](url)` renders a photo, audio or video by file
150
+ extension. You cannot upload, so never write one pointing at a URL you guessed — it renders
151
+ broken, which reads as their photo being lost. Move existing lines; never fabricate one.