@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/agents-md-text.js +64 -0
- package/dist/browser-login.js +6 -1
- package/dist/build-channel.js +2 -2
- package/dist/cli.js +18 -0
- package/dist/commands/agents-md.js +118 -0
- package/dist/commands/help.js +21 -0
- package/dist/commands/self-update.js +32 -5
- package/dist/daemon.js +104 -30
- package/dist/dashboard.js +118 -6
- package/dist/ipc-server.js +11 -8
- package/dist/npm-update.js +138 -0
- package/dist/verb-surface.js +1 -0
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +6 -0
- package/skills/agent-doodle-space/SKILL.md +1 -1
- package/skills/agent-home/SKILL.md +1 -1
- package/skills/brand-identity/SKILL.md +1 -0
- package/skills/clearly-agent/SKILL.md +1 -1
- package/skills/clearly-canvas/SKILL.md +1 -0
- package/skills/clearly-docs/SKILL.md +151 -0
- package/skills/clearly-init/SKILL.md +135 -0
- package/skills/clearly-workflows/SKILL.md +90 -0
- package/skills/clearly-workspace/SKILL.md +1 -1
- package/skills/codebase-map/SKILL.md +1 -1
- package/skills/index.json +43 -8
- package/skills/pair-on-canvas/SKILL.md +1 -1
- package/skills/sticker-pack/SKILL.md +132 -0
- package/skills/visualize/SKILL.md +188 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/ipc-server.js
CHANGED
|
@@ -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
|
-
|
|
613
|
-
|
|
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
|
+
}
|
package/dist/verb-surface.js
CHANGED
|
@@ -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
package/scripts/sync-skills.mjs
CHANGED
|
@@ -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.** `` 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.
|