@vercel/board 0.19.0 → 0.20.1
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/{a2-C1obi0Qb.mjs → a2-QtmN9EhW.mjs} +36 -6
- package/dist/access.mjs +3 -3
- package/dist/{actors-BOamkEuz.mjs → actors-rPnEQLZu.mjs} +2 -2
- package/dist/{auth-Drg9FiC2.mjs → auth-5nLsx9qB.mjs} +1 -1
- package/dist/{backend-B5GCP1oK.d.mts → backend-CWmd4UH-.d.mts} +8 -1
- package/dist/backend-a2.d.mts +22 -4
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Ca6b0FPA.d.mts → backend-http-BfWQfvqB.d.mts} +22 -4
- package/dist/{backend-http-BMVDbtD_.mjs → backend-http-CbcjkHEn.mjs} +7 -3
- package/dist/{backend-http-DMpQ750P.mjs → backend-http-maHy-JW3.mjs} +19 -6
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{chat-provider-B5uCc0an.d.mts → chat-provider-D0k3y8zY.d.mts} +4 -1
- package/dist/{chat-provider-YHy-WbrB.mjs → chat-provider-DnHibqnM.mjs} +4 -1
- package/dist/cli-embedded.d.mts +47 -0
- package/dist/cli-embedded.mjs +64 -0
- package/dist/cli.mjs +73 -1661
- package/dist/{client-BA-J7usH.mjs → client-ClznIBMg.mjs} +2 -0
- package/dist/{conformance-D0Z4dXC_.mjs → conformance-DQ9qVzWP.mjs} +1 -1
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-D0oBUBEK.mjs → engine-kHfSOXnQ.mjs} +15 -4
- package/dist/{errors-DX3hpwWt.mjs → errors-CQ-fwxM3.mjs} +13 -1
- package/dist/{exports-CUGshfRF.mjs → exports-Dd8YdgO-.mjs} +15 -6
- package/dist/exports-ggfE9jNv.d.mts +4 -0
- package/dist/index-DsMPCv_K.d.mts +11 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Cm6GsLdj.mjs → index.default-BqstUHvz.mjs} +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.node.d.mts +4 -12
- package/dist/index.node.mjs +4 -4
- package/dist/inspect/app.js +93 -92
- package/dist/inspect/engine.js +4 -4
- package/dist/main-CRLa7i3I.mjs +1745 -0
- package/dist/{middleware-CqaCwucP.mjs → middleware-DUjyQP_a.mjs} +1 -1
- package/dist/{provider-B1Z3Ok86.d.mts → provider-D11Dfda0.d.mts} +86 -32
- package/dist/{provider-B3DpYSDq.mjs → provider-poIkXapd.mjs} +4 -0
- package/dist/{serve-DB9ijyQ7.mjs → serve-1kBFSHe_.mjs} +4 -4
- package/dist/{server-BujqWBP5.mjs → server-CuqhG9E6.mjs} +4 -4
- package/dist/{serving-CNkgUPW1.mjs → serving-B307gppu.mjs} +1 -1
- package/dist/sync-github.d.mts +2 -2
- package/dist/sync-github.mjs +2 -2
- package/dist/sync-kapso.d.mts +2 -2
- package/dist/sync-kapso.mjs +2 -2
- package/dist/sync-linear.d.mts +2 -2
- package/dist/sync-linear.mjs +2 -2
- package/dist/sync-slack.d.mts +11 -2
- package/dist/sync-slack.mjs +57 -17
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +1 -1
- package/dist/sync.mjs +1 -1
- package/dist/{tokens-DhuzKzAl.mjs → tokens-DUXaXwOj.mjs} +2 -2
- package/dist/{watchers-hash-B2TevhFZ.mjs → watchers-hash-Cd6ZLo9m.mjs} +38 -25
- package/docs/02-sync/03-slack.md +14 -1
- package/docs/02-sync/06-providers.md +1 -0
- package/docs/04-reference/01-cli.md +53 -2
- package/docs/04-reference/02-board.md +8 -3
- package/docs/04-reference/03-http.md +4 -3
- package/package.json +5 -1
- package/dist/exports-B3AFzT_f.d.mts +0 -4
|
@@ -0,0 +1,1745 @@
|
|
|
1
|
+
import { i as fileSize, n as DIRECT_UPLOAD_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
2
|
+
import { a as AttachmentId } from "./schemas-Be08nyfW.mjs";
|
|
3
|
+
import { a as withMentions, f as VERSION, i as FILTER_FLAGS } from "./watchers-hash-Cd6ZLo9m.mjs";
|
|
4
|
+
import { n as displayEmoji, r as normalizeEmoji } from "./emoji-CTLpvH-A.mjs";
|
|
5
|
+
import { t as BoardError } from "./client-ClznIBMg.mjs";
|
|
6
|
+
import { a as detectAgent, c as projectUrl, i as connect, n as boardUrl, r as configDir, s as projectDir, t as http } from "./backend-http-maHy-JW3.mjs";
|
|
7
|
+
import { t as board } from "./server-CuqhG9E6.mjs";
|
|
8
|
+
import { t as conformance } from "./conformance-DQ9qVzWP.mjs";
|
|
9
|
+
import { t as displayText } from "./bots-CGkO76qH.mjs";
|
|
10
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir, tmpdir } from "node:os";
|
|
15
|
+
import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
//#region src/cli/format.ts
|
|
18
|
+
/** "claude via julian" for a profile, the name otherwise. */
|
|
19
|
+
/**
|
|
20
|
+
* Text for a terminal. What boards and platforms say can carry control characters, ESC among them,
|
|
21
|
+
* which starts the sequences that clear the screen, move the cursor, or retitle the window: each
|
|
22
|
+
* but tab and newline shows as its picture (`␛`), a C1 control as `�`, and a Windows line end as a
|
|
23
|
+
* newline. `--json` prints what the board said, as it said it.
|
|
24
|
+
*/
|
|
25
|
+
const terminalText = (text) => text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (char) => {
|
|
26
|
+
const code = char.charCodeAt(0);
|
|
27
|
+
return code < 32 ? String.fromCharCode(9216 + code) : code === 127 ? "␡" : "�";
|
|
28
|
+
});
|
|
29
|
+
const who = (me) => me.profile !== null && me.handle.includes("/") ? `${me.name} via ${me.handle.slice(0, me.handle.indexOf("/"))}` : me.name;
|
|
30
|
+
function authorName(message) {
|
|
31
|
+
const author = message.author;
|
|
32
|
+
if (!author) return message.by.actor;
|
|
33
|
+
const slash = author.handle.indexOf("/");
|
|
34
|
+
if (message.by.profile !== null && slash !== -1) return `${author.name} via ${author.handle.slice(0, slash)}`;
|
|
35
|
+
if (author.person && author.person.handle !== author.handle) return `${author.name} (${author.person.handle})`;
|
|
36
|
+
return author.name;
|
|
37
|
+
}
|
|
38
|
+
function ago(iso, now = Date.now()) {
|
|
39
|
+
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
|
|
40
|
+
if (seconds < 60) return `${seconds}s`;
|
|
41
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
42
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
43
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
44
|
+
}
|
|
45
|
+
const indent = (text, prefix) => text.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
46
|
+
function labels(message) {
|
|
47
|
+
const entries = Object.entries(message.labels);
|
|
48
|
+
return entries.length ? ` [${entries.map(([k, v]) => `${k}=${v}`).join(" ")}]` : "";
|
|
49
|
+
}
|
|
50
|
+
function reactions(message, names) {
|
|
51
|
+
const groups = Object.entries(message.reactions);
|
|
52
|
+
if (groups.length === 0) return "";
|
|
53
|
+
return groups.map(([emoji, actors]) => {
|
|
54
|
+
const shown = actors.slice(0, 3).map((id) => names.get(id) ?? id).join(", ");
|
|
55
|
+
return `${displayEmoji(emoji)} ${shown}${actors.length > 3 ? ` +${actors.length - 3}` : ""}`;
|
|
56
|
+
}).join(" ");
|
|
57
|
+
}
|
|
58
|
+
/** The actors a thread's reactions name on screen, for looking up the ones who didn't write in it. */
|
|
59
|
+
function reactionNames(messages) {
|
|
60
|
+
return [...new Set(messages.flatMap((m) => Object.values(m.reactions).flatMap((actors) => actors.slice(0, 3))))];
|
|
61
|
+
}
|
|
62
|
+
/** An actor's display name from `/actors`, the way a message's author shows: `claude via julian`. */
|
|
63
|
+
function actorName(actor) {
|
|
64
|
+
const slash = actor.handle.indexOf("/");
|
|
65
|
+
return slash === -1 ? actor.name : `${actor.name} via ${actor.handle.slice(0, slash)}`;
|
|
66
|
+
}
|
|
67
|
+
/** Lines of a linked message an embed shows before it trails off. */
|
|
68
|
+
const EMBED_LINES = 12;
|
|
69
|
+
/** A linked platform message, quoted under the message that links it, or why the board couldn't read it. */
|
|
70
|
+
function embed(link, pad) {
|
|
71
|
+
if (!link.snapshot) return [`${pad}↪ ${link.url} (couldn't read it: ${link.unavailable ?? "unknown"})`];
|
|
72
|
+
const { author, text, files } = link.snapshot;
|
|
73
|
+
const lines = text.trim() ? text.trim().split("\n") : [];
|
|
74
|
+
const shown = lines.length > EMBED_LINES ? [...lines.slice(0, EMBED_LINES), "…"] : lines;
|
|
75
|
+
return [
|
|
76
|
+
`${pad}↪ ${author}${link.message !== null ? ` · #${link.message}` : ""} · ${link.url}`,
|
|
77
|
+
...shown.map((line) => `${pad}> ${line}`),
|
|
78
|
+
...files.map((file) => `${pad}> file ${file.name}${file.url ? ` ${file.url}` : ""}`)
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
/** One message as a block: header line, indented text, reactions. */
|
|
82
|
+
function formatMessage(message, options = {}) {
|
|
83
|
+
const pad = options.reply ? " " : " ";
|
|
84
|
+
const head = `${options.reply ? " ↳ " : ""}#${message.id} ${authorName(message)} · ${ago(message.createdAt)}${message.editedAt ? " (edited)" : ""}${message.schedule ? ` · scheduled s${message.schedule.id}` : ""}${labels(message)}`;
|
|
85
|
+
const body = message.deleted ? `${pad}(message removed)` : indent(displayText(message.text, message.by.actor), pad);
|
|
86
|
+
const files = (message.attachments ?? []).map((a) => `${pad}file ${a.name} · ${a.type} · ${fileSize(a.size)} · ${a.id}`);
|
|
87
|
+
const links = message.deleted ? [] : (message.links ?? []).flatMap((link) => embed(link, pad));
|
|
88
|
+
const reacted = reactions(message, options.names ?? /* @__PURE__ */ new Map());
|
|
89
|
+
const undelivered = Object.entries(message.undelivered ?? {}).map(([target, error]) => `${pad}✗ didn't reach ${target}: ${error}`);
|
|
90
|
+
return [
|
|
91
|
+
head,
|
|
92
|
+
body,
|
|
93
|
+
...files,
|
|
94
|
+
...links,
|
|
95
|
+
...reacted ? [`${pad}${reacted}`] : [],
|
|
96
|
+
...undelivered
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
/** Threads in order, each root followed by its replies. */
|
|
100
|
+
function formatThreads(messages, known = /* @__PURE__ */ new Map()) {
|
|
101
|
+
const names = new Map([...known, ...messages.map((m) => [m.by.actor, authorName(m)])]);
|
|
102
|
+
const byRoot = /* @__PURE__ */ new Map();
|
|
103
|
+
const roots = [];
|
|
104
|
+
const orphans = [];
|
|
105
|
+
for (const message of messages) if (message.re === null) roots.push(message);
|
|
106
|
+
else (byRoot.get(message.re) ?? byRoot.set(message.re, []).get(message.re)).push(message);
|
|
107
|
+
const rootIds = new Set(roots.map((r) => r.id));
|
|
108
|
+
for (const [root, replies] of byRoot) if (!rootIds.has(root)) orphans.push(...replies);
|
|
109
|
+
const blocks = roots.map((root) => [formatMessage(root, { names }), ...(byRoot.get(root.id) ?? []).map((r) => formatMessage(r, {
|
|
110
|
+
reply: true,
|
|
111
|
+
names
|
|
112
|
+
}))].join("\n"));
|
|
113
|
+
for (const reply of orphans) blocks.push(`(in thread #${reply.re})\n${formatMessage(reply, {
|
|
114
|
+
reply: true,
|
|
115
|
+
names
|
|
116
|
+
})}`);
|
|
117
|
+
return blocks.join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
/** A root's first line, cut to fit 80 columns under its header. */
|
|
120
|
+
function firstLine(message) {
|
|
121
|
+
if (message.deleted) return "(message removed)";
|
|
122
|
+
const text = displayText(message.text, message.by.actor).trim();
|
|
123
|
+
const files = message.attachments?.length ?? 0;
|
|
124
|
+
if (!text) return files ? `(${files} ${files === 1 ? "file" : "files"})` : "(no text)";
|
|
125
|
+
const [line = "", ...rest] = text.split(/\r?\n/);
|
|
126
|
+
const shown = rest.length ? `${line.trimEnd()} …` : line;
|
|
127
|
+
return shown.length > 74 ? `${line.slice(0, 73)}…` : shown;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* One block per thread, for `read <channel>`: the root's header and first line,
|
|
131
|
+
* then how many replies it has and who wrote the latest, and when.
|
|
132
|
+
*/
|
|
133
|
+
function formatThreadSummaries(threads, options = {}) {
|
|
134
|
+
const now = options.now ?? Date.now();
|
|
135
|
+
return threads.map(({ message, replies, last }) => {
|
|
136
|
+
const where = options.channel ? `${message.channel} ` : "";
|
|
137
|
+
const head = `#${message.id} ${where}${authorName(message)} · ${ago(message.createdAt, now)}${labels(message)}`;
|
|
138
|
+
const activity = last ? `${replies} ${replies === 1 ? "reply" : "replies"} · last #${last.id} ${authorName(last)} · ${ago(last.createdAt, now)}` : "no replies";
|
|
139
|
+
return [
|
|
140
|
+
head,
|
|
141
|
+
` ${firstLine(message)}`,
|
|
142
|
+
` ${activity}`
|
|
143
|
+
].join("\n");
|
|
144
|
+
}).join("\n\n");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* `/me`'s `can` as `whoami` rows, a pattern and its permissions each (`ci/ read
|
|
148
|
+
* post`), or none for full access. `manage` brings everything, so it says `all`.
|
|
149
|
+
*/
|
|
150
|
+
function formatAccess(access) {
|
|
151
|
+
const patterns = Object.keys(access);
|
|
152
|
+
if (patterns.length === 1 && patterns[0] === "*" && access["*"].includes("manage")) return [];
|
|
153
|
+
if (patterns.length === 0) return ["nothing"];
|
|
154
|
+
const width = Math.max(...patterns.map((pattern) => pattern.length));
|
|
155
|
+
return patterns.map((pattern) => {
|
|
156
|
+
const permissions = access[pattern];
|
|
157
|
+
return `${pattern.padEnd(width)} ${permissions.includes("manage") ? "all" : permissions.length === 0 ? "nothing" : permissions.join(" ")}`;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** `90m` → `1h30m`, `1d`: milliseconds as a duration flag would say them. */
|
|
161
|
+
function span(ms) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
let rest = Math.round(ms / 1e3);
|
|
164
|
+
for (const [unit, size] of [
|
|
165
|
+
["d", 86400],
|
|
166
|
+
["h", 3600],
|
|
167
|
+
["m", 60],
|
|
168
|
+
["s", 1]
|
|
169
|
+
]) {
|
|
170
|
+
if (rest >= size) parts.push(`${Math.floor(rest / size)}${unit}`);
|
|
171
|
+
rest %= size;
|
|
172
|
+
}
|
|
173
|
+
return parts.join("") || "0s";
|
|
174
|
+
}
|
|
175
|
+
/** `in 25m`, or `due` once it's past. */
|
|
176
|
+
const until = (iso, now) => {
|
|
177
|
+
const ms = Date.parse(iso) - now;
|
|
178
|
+
if (ms <= 0) return "due";
|
|
179
|
+
return `in ${span(ms < 6e4 ? Math.ceil(ms / 1e3) * 1e3 : Math.ceil(ms / 6e4) * 6e4)}`;
|
|
180
|
+
};
|
|
181
|
+
/** One schedule: what it sends, when, where, and how it stops. */
|
|
182
|
+
function formatSchedule(schedule, options = {}) {
|
|
183
|
+
const now = options.now ?? Date.now();
|
|
184
|
+
const pad = options.pad ?? "";
|
|
185
|
+
const where = schedule.re === null ? `new thread in ${schedule.channel}` : `#${schedule.re}`;
|
|
186
|
+
const repeat = schedule.every === null ? "once" : `every ${span(schedule.every)}`;
|
|
187
|
+
const stop = schedule.every === null ? "" : schedule.times !== null ? ` · ${schedule.times} times` : schedule.until !== null ? ` · until ${schedule.until}` : " · until canceled";
|
|
188
|
+
const when = schedule.state === "pending" ? `next ${until(schedule.next, now)} (${schedule.next})` : schedule.state === "canceled" ? schedule.canceled?.reason === "thread_deleted" ? "ended: the thread was deleted" : schedule.canceled?.reason === "dropped" ? "ended: the board dropped it" : schedule.canceled?.reason === "revoked" ? "ended: its creator can no longer post it" : "canceled" : "done";
|
|
189
|
+
const sent = schedule.sent > 0 ? ` · ${schedule.sent} sent` : "";
|
|
190
|
+
const files = schedule.attachments.length ? ` · ${schedule.attachments.length} ${schedule.attachments.length === 1 ? "file" : "files"}` : "";
|
|
191
|
+
const text = displayText(schedule.text, schedule.by.actor);
|
|
192
|
+
return [`${pad}s${schedule.id} ${where} · ${repeat}${stop} · ${when}${sent}${files} · by ${authorName(schedule)}`, indent(text || "(no text)", `${pad} `)].join("\n");
|
|
193
|
+
}
|
|
194
|
+
/** One line per live event, for `tail` and `wait`. */
|
|
195
|
+
function formatEvent(event) {
|
|
196
|
+
const message = event.message;
|
|
197
|
+
if (!message) return `#${event.cursor} ${event.type}`;
|
|
198
|
+
const where = message.re === null ? message.channel : `${message.channel} ↳ #${message.re}`;
|
|
199
|
+
const who = authorName(message);
|
|
200
|
+
switch (event.type) {
|
|
201
|
+
case "message.posted": {
|
|
202
|
+
const files = (message.attachments ?? []).map((a) => ` · file ${a.name}`).join("");
|
|
203
|
+
return `#${message.id} ${where} ${who}: ${displayText(message.text, message.by.actor)}${files}`;
|
|
204
|
+
}
|
|
205
|
+
case "message.edited": return `#${message.id} ${where} ${who} (edited): ${displayText(message.text, message.by.actor)}`;
|
|
206
|
+
case "message.deleted": return `#${message.id} ${where} (message removed)`;
|
|
207
|
+
case "message.linked": return `#${message.id} ${where} ↪ links ${(message.links ?? []).map((link) => link.message !== null ? `#${link.message}` : link.url).join(", ") || "nothing"}`;
|
|
208
|
+
case "reaction.added":
|
|
209
|
+
case "reaction.removed": return `#${message.id} ${where} ${displayEmoji(event.payload.emoji)} ${event.type === "reaction.added" ? "added" : "removed"} by ${event.actor?.name ?? event.payload.by.actor}`;
|
|
210
|
+
case "labels.changed": {
|
|
211
|
+
const set = Object.entries(event.payload.set).map(([k, v]) => `${k}=${v}`);
|
|
212
|
+
const unset = event.payload.unset.map((k) => `-${k}`);
|
|
213
|
+
return `#${message.id} ${where} labels ${[...set, ...unset].join(" ")}${event.actor ? ` by ${event.actor.name}` : ""}`;
|
|
214
|
+
}
|
|
215
|
+
case "message.sync_failed": return `#${message.id} ${where} ✗ didn't reach ${event.payload.target}: ${event.payload.error}`;
|
|
216
|
+
default: return `#${event.cursor} ${event.type}`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Where a thread is synced, for `vb sync <id>`: a row a target, its sync key and where it is, then
|
|
221
|
+
* the link to the message's copy there, or why it didn't reach it.
|
|
222
|
+
*/
|
|
223
|
+
function formatSynced(targets) {
|
|
224
|
+
const where = (row) => row.target.slice(row.key.length + 1);
|
|
225
|
+
const keys = Math.max(...targets.map((row) => row.key.length));
|
|
226
|
+
const wheres = Math.max(...targets.map((row) => where(row).length));
|
|
227
|
+
return targets.map((row) => {
|
|
228
|
+
const copy = row.undelivered !== null ? `didn't reach it: ${row.undelivered}` : row.url ?? (row.message !== null ? `message ${row.message}` : "no copy yet");
|
|
229
|
+
return `${row.key.padEnd(keys)} ${where(row).padEnd(wheres)} ${terminalText(copy)}`;
|
|
230
|
+
}).join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A channel or thread read with `vb sources <key> read`: a header, then each message under its ID,
|
|
234
|
+
* with the target that reads its thread when it has replies.
|
|
235
|
+
*/
|
|
236
|
+
function formatSourceRead(key, read, now = Date.now(), cli = "vb") {
|
|
237
|
+
const where = `${key}:${read.channel}${read.name ? ` (#${read.name})` : ""}${read.thread ? `, thread ${read.thread}` : ""}`;
|
|
238
|
+
if (read.messages.length === 0) return `${where}\nno messages`;
|
|
239
|
+
const blocks = read.messages.map((message) => {
|
|
240
|
+
const reply = read.thread !== null && message.id !== read.thread;
|
|
241
|
+
const pad = reply ? " " : " ";
|
|
242
|
+
const when = message.createdAt ? ` · ${ago(message.createdAt, now)}` : "";
|
|
243
|
+
const replies = message.replies > 0 && read.thread === null ? ` (${message.replies} ${message.replies === 1 ? "reply" : "replies"}: ${cli} sources ${key} read ${message.thread})` : "";
|
|
244
|
+
return [
|
|
245
|
+
`${reply ? " ↳ " : ""}${message.id} ${message.author}${when}${replies}`,
|
|
246
|
+
indent(message.text || "(no text)", pad),
|
|
247
|
+
...message.files.map((file) => `${pad}file ${file.name}${file.url ? ` ${file.url}` : ""}`)
|
|
248
|
+
].join("\n");
|
|
249
|
+
});
|
|
250
|
+
const more = read.more ? [`(${read.thread ? "later replies" : "older messages"} not shown: raise --limit, up to 200)`] : [];
|
|
251
|
+
return [
|
|
252
|
+
where,
|
|
253
|
+
...blocks,
|
|
254
|
+
...more
|
|
255
|
+
].join("\n");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A conformance report: each group a heading, its checks under it, and why for one that failed
|
|
259
|
+
* or was skipped, then the counts.
|
|
260
|
+
*/
|
|
261
|
+
function formatConformance(report) {
|
|
262
|
+
const blocks = [];
|
|
263
|
+
for (const group of [...new Set(report.checks.map((check) => check.group))]) {
|
|
264
|
+
const lines = [group];
|
|
265
|
+
for (const check of report.checks.filter((c) => c.group === group)) {
|
|
266
|
+
lines.push(` ${check.status.padEnd(7)} ${check.name}`);
|
|
267
|
+
if (check.detail) lines.push(` ${check.detail}`);
|
|
268
|
+
}
|
|
269
|
+
blocks.push(lines.join("\n"));
|
|
270
|
+
}
|
|
271
|
+
const count = (status) => report.checks.filter((check) => check.status === status).length;
|
|
272
|
+
const counts = [
|
|
273
|
+
`${count("passed")} passed`,
|
|
274
|
+
...count("skipped") ? [`${count("skipped")} skipped`] : [],
|
|
275
|
+
...count("failed") ? [`${count("failed")} failed`] : []
|
|
276
|
+
];
|
|
277
|
+
return `${blocks.join("\n\n")}\n\n${counts.join(", ")}`;
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/cli/hooks.ts
|
|
281
|
+
/**
|
|
282
|
+
* Where each agent keeps hooks, and which of its events run ours. Claude Code
|
|
283
|
+
* and Codex read `{ hooks: { Stop: [{ hooks: [entry] }] } }`; Cursor reads
|
|
284
|
+
* `{ version: 1, hooks: { stop: [entry] } }`, and its stop can't block, so it's
|
|
285
|
+
* reminded with a follow-up message instead. Pi has no hook files: its hook is
|
|
286
|
+
* an extension we write whole (`piExtension`), in `$PI_CODING_AGENT_DIR/extensions`.
|
|
287
|
+
*/
|
|
288
|
+
const AGENTS = {
|
|
289
|
+
claude: {
|
|
290
|
+
name: "Claude Code",
|
|
291
|
+
shape: "nested",
|
|
292
|
+
event: "Stop",
|
|
293
|
+
file: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json")
|
|
294
|
+
},
|
|
295
|
+
codex: {
|
|
296
|
+
name: "Codex",
|
|
297
|
+
shape: "nested",
|
|
298
|
+
event: "Stop",
|
|
299
|
+
file: () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "hooks.json")
|
|
300
|
+
},
|
|
301
|
+
cursor: {
|
|
302
|
+
name: "Cursor",
|
|
303
|
+
shape: "flat",
|
|
304
|
+
event: "stop",
|
|
305
|
+
file: () => join(homedir(), ".cursor", "hooks.json")
|
|
306
|
+
},
|
|
307
|
+
pi: {
|
|
308
|
+
name: "Pi",
|
|
309
|
+
shape: "extension",
|
|
310
|
+
event: "agent_before_settle",
|
|
311
|
+
file: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "extensions", "board.ts")
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const ours = (entry) => / hook stop --agent /.test(entry.command ?? "");
|
|
315
|
+
function readConfig(path) {
|
|
316
|
+
try {
|
|
317
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error.code === "ENOENT") return {};
|
|
320
|
+
throw new Error(`can't read ${path}: ${error.message}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Written to a sibling, then renamed: a crash never leaves an agent's settings half-written. */
|
|
324
|
+
function writeConfig(path, config) {
|
|
325
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
326
|
+
writeFileSync(`${path}.board-tmp`, `${JSON.stringify(config, null, 2)}\n`);
|
|
327
|
+
renameSync(`${path}.board-tmp`, path);
|
|
328
|
+
}
|
|
329
|
+
/** An event's entries without ours; a group left with no hooks goes too. */
|
|
330
|
+
function without(entries, shape) {
|
|
331
|
+
if (shape === "flat") return entries.filter((entry) => !ours(entry));
|
|
332
|
+
return entries.map((group) => ({
|
|
333
|
+
...group,
|
|
334
|
+
hooks: (group.hooks ?? []).filter((h) => !ours(h))
|
|
335
|
+
})).filter((group) => group.hooks.length > 0);
|
|
336
|
+
}
|
|
337
|
+
/** The line of our Pi extension that holds the command it runs: how status finds it. */
|
|
338
|
+
const PI_COMMAND = /^const COMMAND = (".*")$/m;
|
|
339
|
+
/**
|
|
340
|
+
* Pi's turn-end hook, as an extension. Before Pi settles a completed run, it
|
|
341
|
+
* runs our command with what Claude Code's hook gets (the session, the
|
|
342
|
+
* directory, and whether this hook made it continue); a reminder comes back
|
|
343
|
+
* as a message, and Pi continues once to act on it. Pi's bash tool exports the
|
|
344
|
+
* same session as `PI_SESSION_ID`, so the agent's `vb reply` or `vb post` counts for it.
|
|
345
|
+
*/
|
|
346
|
+
function piExtension(command) {
|
|
347
|
+
return `// Added by \`vb hooks install pi\`: reminds Pi, once per turn, to update the team's board.
|
|
348
|
+
// \`vb hooks uninstall pi\` removes it; reinstalling replaces this file.
|
|
349
|
+
import { spawn } from 'node:child_process'
|
|
350
|
+
|
|
351
|
+
const COMMAND = ${JSON.stringify(command)}
|
|
352
|
+
|
|
353
|
+
function run(cwd: string, input: string): Promise<string> {
|
|
354
|
+
return new Promise((resolve) => {
|
|
355
|
+
let out = ''
|
|
356
|
+
const child = spawn('/bin/sh', ['-c', COMMAND], { cwd, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
357
|
+
const timer = setTimeout(() => child.kill(), 10_000)
|
|
358
|
+
child.stdout.on('data', (chunk) => (out += chunk))
|
|
359
|
+
child.on('error', () => resolve(''))
|
|
360
|
+
child.on('close', () => (clearTimeout(timer), resolve(out)))
|
|
361
|
+
child.stdin.on('error', () => {})
|
|
362
|
+
child.stdin.end(input)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export default function board(pi: any) {
|
|
367
|
+
let reminded = false
|
|
368
|
+
pi.on('agent_before_settle', async (event: any, ctx: any) => {
|
|
369
|
+
const continuing = reminded
|
|
370
|
+
reminded = false
|
|
371
|
+
if (event.outcome !== 'completed') return
|
|
372
|
+
try {
|
|
373
|
+
const input = JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, stop_hook_active: continuing })
|
|
374
|
+
const reason = JSON.parse((await run(ctx.cwd, input)) || '{}').reason
|
|
375
|
+
if (typeof reason !== 'string' || !reason) return
|
|
376
|
+
reminded = true
|
|
377
|
+
return { entries: [...event.entries, { type: 'custom_message', customType: 'board', content: reason, display: true }], continue: true }
|
|
378
|
+
} catch {}
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The command our hook runs. `|| true`: whatever `cli` turns out to be (an older board without
|
|
385
|
+
* `hook` exits 2, which Claude Code and Codex read as "keep going"), the hook can't fail a turn.
|
|
386
|
+
*/
|
|
387
|
+
const hookCommand = (agent, cli, flags = "") => `${cli} hook stop --agent ${agent}${flags} 2>/dev/null || true`;
|
|
388
|
+
/** Adds our hook, replacing an earlier one of ours; everything else in the file stays. Answers whether it changed. */
|
|
389
|
+
function installHook(agent, cli, flags = "") {
|
|
390
|
+
const spec = AGENTS[agent];
|
|
391
|
+
const command = hookCommand(agent, cli, flags);
|
|
392
|
+
if (spec.shape === "extension") {
|
|
393
|
+
const source = piExtension(command);
|
|
394
|
+
let current = null;
|
|
395
|
+
try {
|
|
396
|
+
current = readFileSync(spec.file(), "utf8");
|
|
397
|
+
} catch {}
|
|
398
|
+
if (current === source) return false;
|
|
399
|
+
if (current !== null && !current.startsWith("// Added by `vb hooks install pi`")) throw new Error(`${spec.file()} isn't board's: move it, then install again`);
|
|
400
|
+
mkdirSync(dirname(spec.file()), { recursive: true });
|
|
401
|
+
writeFileSync(`${spec.file()}.board-tmp`, source);
|
|
402
|
+
renameSync(`${spec.file()}.board-tmp`, spec.file());
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
const config = readConfig(spec.file());
|
|
406
|
+
const entry = spec.shape === "flat" ? {
|
|
407
|
+
command,
|
|
408
|
+
timeout: 10
|
|
409
|
+
} : { hooks: [{
|
|
410
|
+
type: "command",
|
|
411
|
+
command,
|
|
412
|
+
timeout: 10
|
|
413
|
+
}] };
|
|
414
|
+
const hooks = {
|
|
415
|
+
...config.hooks,
|
|
416
|
+
[spec.event]: [...without(config.hooks?.[spec.event] ?? [], spec.shape), entry]
|
|
417
|
+
};
|
|
418
|
+
const next = {
|
|
419
|
+
...spec.shape === "flat" ? { version: 1 } : {},
|
|
420
|
+
...config,
|
|
421
|
+
hooks
|
|
422
|
+
};
|
|
423
|
+
if (JSON.stringify(next) === JSON.stringify(config)) return false;
|
|
424
|
+
writeConfig(spec.file(), next);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
/** Removes our hook. A file that held only it (Cursor's, which we created) goes away with it. */
|
|
428
|
+
function uninstallHook(agent) {
|
|
429
|
+
const spec = AGENTS[agent];
|
|
430
|
+
if (installedHook(agent) === null) return false;
|
|
431
|
+
if (spec.shape === "extension") return unlinkSync(spec.file()), true;
|
|
432
|
+
const config = readConfig(spec.file());
|
|
433
|
+
const hooks = { ...config.hooks };
|
|
434
|
+
const kept = without(hooks[spec.event] ?? [], spec.shape);
|
|
435
|
+
if (kept.length > 0) hooks[spec.event] = kept;
|
|
436
|
+
else delete hooks[spec.event];
|
|
437
|
+
if (Object.keys(config).filter((key) => key !== "hooks" && key !== "version").length === 0 && Object.keys(hooks).length === 0) unlinkSync(spec.file());
|
|
438
|
+
else writeConfig(spec.file(), {
|
|
439
|
+
...config,
|
|
440
|
+
hooks
|
|
441
|
+
});
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
/** The command our hook runs for `agent`, if it's installed. */
|
|
445
|
+
function installedHook(agent) {
|
|
446
|
+
const spec = AGENTS[agent];
|
|
447
|
+
if (spec.shape === "extension") try {
|
|
448
|
+
const line = PI_COMMAND.exec(readFileSync(spec.file(), "utf8"));
|
|
449
|
+
return line ? JSON.parse(line[1]) : null;
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const entries = readConfig(spec.file()).hooks?.[spec.event] ?? [];
|
|
454
|
+
return (spec.shape === "flat" ? entries : entries.flatMap((group) => group.hooks ?? [])).find(ours)?.command ?? null;
|
|
455
|
+
}
|
|
456
|
+
/** A path under the home directory as `~/…`, the way people write it. */
|
|
457
|
+
function tilde(path) {
|
|
458
|
+
const home = homedir();
|
|
459
|
+
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
460
|
+
}
|
|
461
|
+
/** The CLI an installed hook runs: everything before ` hook stop`. */
|
|
462
|
+
const cliOf = (command) => command.slice(0, command.indexOf(" hook stop "));
|
|
463
|
+
/**
|
|
464
|
+
* The reminder asks the agent to run `vb reply`, `vb post`, `vb read`, and `vb channels`;
|
|
465
|
+
* where the agent keeps an allowlist, those run without a permission prompt.
|
|
466
|
+
* Claude Code matches command prefixes; Cursor matches a command's name, so it
|
|
467
|
+
* can allow only a `vb` on PATH. Codex has no command allowlist: its sandbox
|
|
468
|
+
* decides (see `codexNetwork`).
|
|
469
|
+
*/
|
|
470
|
+
const REMINDED = [
|
|
471
|
+
"reply",
|
|
472
|
+
"post",
|
|
473
|
+
"read",
|
|
474
|
+
"channels"
|
|
475
|
+
];
|
|
476
|
+
/** Rules an earlier install added that this one doesn't: `vb update`, since removed. Uninstall drops them too. */
|
|
477
|
+
function legacyRules(agent, cli) {
|
|
478
|
+
return agent === "claude" ? [`Bash(${cli} update:*)`] : [];
|
|
479
|
+
}
|
|
480
|
+
function allowRules(agent, cli) {
|
|
481
|
+
if (agent === "claude") return {
|
|
482
|
+
file: AGENTS.claude.file(),
|
|
483
|
+
rules: REMINDED.map((sub) => `Bash(${cli} ${sub}:*)`)
|
|
484
|
+
};
|
|
485
|
+
if (agent === "cursor" && cli === "vb") return {
|
|
486
|
+
file: join(homedir(), ".cursor", "cli-config.json"),
|
|
487
|
+
rules: ["Shell(vb)"]
|
|
488
|
+
};
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
/** Adds the rules a file doesn't have yet. Answers the ones it added. */
|
|
492
|
+
function allow(file, rules) {
|
|
493
|
+
const config = readConfig(file);
|
|
494
|
+
const allowed = config.permissions?.allow ?? [];
|
|
495
|
+
const added = rules.filter((rule) => !allowed.includes(rule));
|
|
496
|
+
if (added.length > 0) writeConfig(file, {
|
|
497
|
+
...config,
|
|
498
|
+
permissions: {
|
|
499
|
+
...config.permissions,
|
|
500
|
+
allow: [...allowed, ...added]
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
return added;
|
|
504
|
+
}
|
|
505
|
+
/** Removes the rules a file has. Answers whether it removed any. */
|
|
506
|
+
function disallow(file, rules) {
|
|
507
|
+
const config = readConfig(file);
|
|
508
|
+
const allowed = config.permissions?.allow;
|
|
509
|
+
if (!allowed?.some((rule) => rules.includes(rule))) return false;
|
|
510
|
+
writeConfig(file, {
|
|
511
|
+
...config,
|
|
512
|
+
permissions: {
|
|
513
|
+
...config.permissions,
|
|
514
|
+
allow: allowed.filter((rule) => !rules.includes(rule))
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Codex's default sandbox has no network, so `vb` can't reach the board. A
|
|
521
|
+
* permission profile can allow just the board, and Vercel's API for its token.
|
|
522
|
+
* In TOML a key after a `[table]` belongs to it, so `default_permissions` goes
|
|
523
|
+
* at the top; the tables go anywhere (`[features]` merged into one that's there).
|
|
524
|
+
*/
|
|
525
|
+
function codexNetwork(boardHost) {
|
|
526
|
+
return {
|
|
527
|
+
top: "default_permissions = \"board\"",
|
|
528
|
+
tables: [
|
|
529
|
+
"[features]",
|
|
530
|
+
"network_proxy = true",
|
|
531
|
+
"",
|
|
532
|
+
"[permissions.board]",
|
|
533
|
+
"extends = \":workspace\"",
|
|
534
|
+
"",
|
|
535
|
+
"[permissions.board.network]",
|
|
536
|
+
"enabled = true",
|
|
537
|
+
"",
|
|
538
|
+
"[permissions.board.network.domains]",
|
|
539
|
+
...[boardHost ?? "<your board host>", "api.vercel.com"].map((host) => `"${host}" = "allow"`)
|
|
540
|
+
].join("\n")
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/** Names in words: `a`, `a or b`, `a, b, or c`. */
|
|
544
|
+
const either = (names) => names.length < 3 ? names.join(" or ") : `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
|
|
545
|
+
/**
|
|
546
|
+
* How an installed hook differs from what installing it again would write, as
|
|
547
|
+
* short phrases (none when it's current): an older hook command or Pi
|
|
548
|
+
* extension, rules for commands since removed, rules it doesn't have yet.
|
|
549
|
+
* It keeps the CLI the hook runs, which `install` may choose differently.
|
|
550
|
+
*/
|
|
551
|
+
function outdated(agent, installed) {
|
|
552
|
+
const spec = AGENTS[agent];
|
|
553
|
+
const cli = cliOf(installed);
|
|
554
|
+
const problems = [];
|
|
555
|
+
if (installed !== hookCommand(agent, cli)) problems.push("runs an older hook command");
|
|
556
|
+
if (spec.shape === "extension") {
|
|
557
|
+
let source = "";
|
|
558
|
+
try {
|
|
559
|
+
source = readFileSync(spec.file(), "utf8");
|
|
560
|
+
} catch {}
|
|
561
|
+
if (source !== piExtension(installed)) problems.push("the extension is from another version");
|
|
562
|
+
}
|
|
563
|
+
const rules = allowRules(agent, cli);
|
|
564
|
+
if (rules) {
|
|
565
|
+
const allowed = readConfig(rules.file).permissions?.allow ?? [];
|
|
566
|
+
const sub = (rule) => {
|
|
567
|
+
const inner = /^\w+\((.*?)(?::\*)?\)$/.exec(rule)?.[1] ?? rule;
|
|
568
|
+
return inner.startsWith(`${cli} `) ? inner.slice(cli.length + 1) : inner;
|
|
569
|
+
};
|
|
570
|
+
const gone = legacyRules(agent, cli).filter((rule) => allowed.includes(rule));
|
|
571
|
+
const missing = rules.rules.filter((rule) => !allowed.includes(rule));
|
|
572
|
+
if (gone.length > 0) problems.push(`allows ${either(gone.map(sub))}, which ${gone.length === 1 ? "is" : "are"} gone`);
|
|
573
|
+
if (missing.length > 0) problems.push(`doesn't allow ${either(missing.map(sub))}`);
|
|
574
|
+
}
|
|
575
|
+
return problems;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* In the temp directory, which agents' sandboxes let commands write (Codex's
|
|
579
|
+
* doesn't allow ~/.config): a marker only has to last a few turns, and the
|
|
580
|
+
* system clears it with the rest of its temp files. `XDG_RUNTIME_DIR`, where
|
|
581
|
+
* there is one, is the user's own.
|
|
582
|
+
*/
|
|
583
|
+
const turnsDir = () => join(process.env.XDG_RUNTIME_DIR || tmpdir(), `board-turns-${process.getuid?.() ?? "user"}`);
|
|
584
|
+
const turnsFile = (session) => join(turnsDir(), `${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
585
|
+
function readTurns(session) {
|
|
586
|
+
try {
|
|
587
|
+
return JSON.parse(readFileSync(turnsFile(session), "utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
return {};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** The marker directory, only if it's ours: in a shared /tmp, someone else could have made it first. */
|
|
593
|
+
function ownTurnsDir() {
|
|
594
|
+
const dir = turnsDir();
|
|
595
|
+
mkdirSync(dir, {
|
|
596
|
+
recursive: true,
|
|
597
|
+
mode: 448
|
|
598
|
+
});
|
|
599
|
+
const { uid, mode } = statSync(dir);
|
|
600
|
+
if (process.getuid && uid !== process.getuid()) throw new Error(`${dir} belongs to someone else`);
|
|
601
|
+
if ((mode & 63) !== 0) chmodSync(dir, 448);
|
|
602
|
+
return dir;
|
|
603
|
+
}
|
|
604
|
+
function writeTurns(session, turns) {
|
|
605
|
+
ownTurnsDir();
|
|
606
|
+
writeFileSync(turnsFile(session), JSON.stringify(turns));
|
|
607
|
+
}
|
|
608
|
+
/** A write to the board from an agent session: this turn needs no reminder. Best effort: the write already happened. */
|
|
609
|
+
function markPosted(session, now = Date.now()) {
|
|
610
|
+
try {
|
|
611
|
+
writeTurns(session, {
|
|
612
|
+
...readTurns(session),
|
|
613
|
+
posted: now
|
|
614
|
+
});
|
|
615
|
+
} catch {}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The turn-end decision: let the agent stop, or remind it once to update the
|
|
619
|
+
* board. It stops freely when it wrote to the board since its last turn
|
|
620
|
+
* ended, when it was already reminded this turn, or when the agent says a
|
|
621
|
+
* hook made it continue (`continuing`), so it can never loop.
|
|
622
|
+
*/
|
|
623
|
+
function decideStop(session, continuing, now = Date.now()) {
|
|
624
|
+
const turns = readTurns(session);
|
|
625
|
+
const since = turns.settled ?? 0;
|
|
626
|
+
if (continuing || (turns.posted ?? 0) > since || (turns.asked ?? 0) > since) {
|
|
627
|
+
writeTurns(session, {
|
|
628
|
+
...turns,
|
|
629
|
+
settled: now
|
|
630
|
+
});
|
|
631
|
+
return "stop";
|
|
632
|
+
}
|
|
633
|
+
writeTurns(session, {
|
|
634
|
+
...turns,
|
|
635
|
+
asked: now
|
|
636
|
+
});
|
|
637
|
+
return "ask";
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* The reminder, with the exact commands to run. It doesn't pick a channel: the
|
|
641
|
+
* agent replies in a thread it's working in, or finds the channel that fits.
|
|
642
|
+
* Finishing without a write is fine, since the hook asks only once a turn.
|
|
643
|
+
*/
|
|
644
|
+
function reminder(cli) {
|
|
645
|
+
return [
|
|
646
|
+
"You didn't update the team's board this turn.",
|
|
647
|
+
`If you changed anything or made progress, share a 1–3 sentence summary of what you did and what's next where the team will see it: ${cli} reply <id> "<text>" in a board thread you're working in, or ${cli} post <channel> "<text>" in the channel that fits (${cli} channels lists them, ${cli} read <channel> shows what's there). Add --attach <file> for proof, like a test log.`,
|
|
648
|
+
`If you want to store memory, you can post to brain-<username>. Channels are public, so never store sensitive information.`,
|
|
649
|
+
"If this turn changed nothing worth sharing (say, you just answered a question), just finish. Don't include the prompt, long output, or secrets."
|
|
650
|
+
].join(" ");
|
|
651
|
+
}
|
|
652
|
+
/** Marks the offer made for `board`. True only for the call that made it, and never when it can't be saved. */
|
|
653
|
+
function claimLinkOffer(board) {
|
|
654
|
+
const file = join(configDir(), "link-offered", createHash("sha256").update(board).digest("hex").slice(0, 32));
|
|
655
|
+
try {
|
|
656
|
+
mkdirSync(dirname(file), {
|
|
657
|
+
recursive: true,
|
|
658
|
+
mode: 448
|
|
659
|
+
});
|
|
660
|
+
writeFileSync(file, "", { flag: "wx" });
|
|
661
|
+
return true;
|
|
662
|
+
} catch {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function linkOffer(cli) {
|
|
667
|
+
return [
|
|
668
|
+
`One more thing, this time only: run ${cli} identity.`,
|
|
669
|
+
"If it says some are not linked (say, slack or github), ask the person in one short question whether they'd like to link them, so the board knows their messages there are theirs and mentions reach them.",
|
|
670
|
+
`If they say yes, run ${cli} identity link <key> --no-wait for each and give them the sign-in URL or code it prints: only they should open or post it.`,
|
|
671
|
+
"If they say no, or there's nothing to link, drop it. This won't come up again."
|
|
672
|
+
].join(" ");
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The reminder as each agent reads it from the hook's stdout. Claude Code
|
|
676
|
+
* shows a stop's `additionalContext` once, as hook feedback, and continues the
|
|
677
|
+
* turn; a blocked stop would also show it as a hook error. Codex and Pi's
|
|
678
|
+
* extension read a blocked stop's `reason`; Cursor, whose stop can't block,
|
|
679
|
+
* a `followup_message`.
|
|
680
|
+
*/
|
|
681
|
+
function stopOutput(agent, text) {
|
|
682
|
+
if (agent === "claude") return { hookSpecificOutput: {
|
|
683
|
+
hookEventName: "Stop",
|
|
684
|
+
additionalContext: text
|
|
685
|
+
} };
|
|
686
|
+
if (agent === "cursor") return { followup_message: text };
|
|
687
|
+
return {
|
|
688
|
+
decision: "block",
|
|
689
|
+
reason: text
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region src/cli/main.ts
|
|
694
|
+
const help = () => `${app.name} ${VERSION}: a message board you mount in one route
|
|
695
|
+
|
|
696
|
+
usage: ${app.name} <command> [options]${app.name === "board" ? " (also installed as `vb`)" : ""}
|
|
697
|
+
|
|
698
|
+
whoami who am I, on which board, with which token, and what I can do
|
|
699
|
+
link <url> link this project to a board (.board/config.json)
|
|
700
|
+
use <url> save the default board
|
|
701
|
+
|
|
702
|
+
channels channels, grouped by path prefix
|
|
703
|
+
actors actors and their handles
|
|
704
|
+
identity your linked Slack and GitHub accounts
|
|
705
|
+
identity link <key> [--code] [--no-wait] link your account on sync entry <key> (e.g. slack): sign in, or post a code from it
|
|
706
|
+
identity unlink <account> unlink one, by handle or actor ID
|
|
707
|
+
|
|
708
|
+
read [channel] [filters] [--limit N] the latest threads, one each: root, replies, latest reply
|
|
709
|
+
(no channel: every channel)
|
|
710
|
+
read <id> one thread, every message (filters: the root and the replies that match)
|
|
711
|
+
read <link> the thread a Slack or GitHub link names, if the board synced it
|
|
712
|
+
|
|
713
|
+
post <channel|id> ["<text>"] [--label k=v]... [--attach <file>]... [--sync <target>]... [--no-sync] [--id <key>]
|
|
714
|
+
a new thread in a channel, or with an id, a reply in its thread
|
|
715
|
+
reply <id> ["<text>"] [--attach <file>]... [--id <key>] [--no-sync]
|
|
716
|
+
schedule <channel|id> ["<text>"] (--in 1d | --at <time> | --every 30m) [--until <time> | --for 24h | --times N]
|
|
717
|
+
[--label k=v]... [--attach <file>]... [--no-sync]
|
|
718
|
+
post later, as you: once, or repeating until a stop or until canceled
|
|
719
|
+
schedules [id] [--by <handle>] [--channel <c>] [--all]
|
|
720
|
+
pending schedules (a thread's, with an id); --all adds done and canceled
|
|
721
|
+
schedule cancel <schedule> stop one, e.g. schedule cancel s12 (yours, or with manage)
|
|
722
|
+
edit <id> "<text>"
|
|
723
|
+
delete <id>
|
|
724
|
+
react <id> <emoji> [--remove]
|
|
725
|
+
label <id> k=v... [--unset k]...
|
|
726
|
+
typing <id> [status] show typing in the thread's synced platform threads (marks WhatsApp read);
|
|
727
|
+
Slack shows the status, if given, instead of "Typing…"
|
|
728
|
+
sync <id> where the thread is synced, with this message's link on each
|
|
729
|
+
sync <id> <target> attach the thread to e.g. slack:#incidents, backfilling it
|
|
730
|
+
inbox <key> [channel] [--limit N] raw platform events, for debugging sync
|
|
731
|
+
sources the board's sources (its sync entries: slack, github, …)
|
|
732
|
+
sources <key> read <channel|thread|link> [--limit N]
|
|
733
|
+
read a source's channel, thread, or message link without syncing it,
|
|
734
|
+
e.g. sources slack read '#general', or a Slack permalink (full access only)
|
|
735
|
+
sources <key> join <channel|link> add the app to a public channel so it can be read, where the board
|
|
736
|
+
opted in (slack({ join: true })); full access only
|
|
737
|
+
download <attachment> [path] save an attachment (its name, here, by default; - for stdout)
|
|
738
|
+
conformance [--channel <c>] check that the board speaks the protocol: posts to conformance,
|
|
739
|
+
kept on the board, and deletes what it posted (1 if a check fails)
|
|
740
|
+
|
|
741
|
+
hooks [install|uninstall|status] [claude|codex|cursor|pi]...
|
|
742
|
+
after a turn that didn't update the board, remind the agent to (linked projects only)
|
|
743
|
+
|
|
744
|
+
tail [channel] [filters] [--all] live, until Ctrl-C
|
|
745
|
+
wait [channel] [filters] [--timeout 30m] block for the next match, print it, then exit (124 on timeout)${Object.keys(app.commands).length > 0 ? `\n\n${Object.values(app.commands).map((command) => command.help).join("\n")}` : ""}
|
|
746
|
+
|
|
747
|
+
filters: --re <id> --by <handle> --from <source> --label k[=v|w] --text <words> --mentions [@handle] --after <id>
|
|
748
|
+
labels match a message or its thread's root; repeated flags all must match
|
|
749
|
+
global: --url <url> --as <profile> --avatar <url> --human --handle <h> --json
|
|
750
|
+
json: one object per line (JSONL): { message, replies, last } for read <channel>, a message for read <id>
|
|
751
|
+
text: pass "-" to read it from stdin; it may be left out when you --attach
|
|
752
|
+
env: BOARD_URL BOARD_TOKEN BOARD_AS BOARD_AVATAR BOARD_HANDLE`;
|
|
753
|
+
var UsageError = class extends Error {};
|
|
754
|
+
const OPTIONS = {
|
|
755
|
+
...FILTER_FLAGS,
|
|
756
|
+
url: { type: "string" },
|
|
757
|
+
as: { type: "string" },
|
|
758
|
+
avatar: { type: "string" },
|
|
759
|
+
human: { type: "boolean" },
|
|
760
|
+
handle: { type: "string" },
|
|
761
|
+
json: { type: "boolean" },
|
|
762
|
+
help: {
|
|
763
|
+
type: "boolean",
|
|
764
|
+
short: "h"
|
|
765
|
+
},
|
|
766
|
+
version: {
|
|
767
|
+
type: "boolean",
|
|
768
|
+
short: "v"
|
|
769
|
+
},
|
|
770
|
+
unset: {
|
|
771
|
+
type: "string",
|
|
772
|
+
multiple: true
|
|
773
|
+
},
|
|
774
|
+
id: { type: "string" },
|
|
775
|
+
"no-sync": { type: "boolean" },
|
|
776
|
+
sync: {
|
|
777
|
+
type: "string",
|
|
778
|
+
multiple: true
|
|
779
|
+
},
|
|
780
|
+
attach: {
|
|
781
|
+
type: "string",
|
|
782
|
+
multiple: true
|
|
783
|
+
},
|
|
784
|
+
channel: { type: "string" },
|
|
785
|
+
agent: { type: "string" },
|
|
786
|
+
remove: { type: "boolean" },
|
|
787
|
+
after: { type: "string" },
|
|
788
|
+
limit: { type: "string" },
|
|
789
|
+
all: { type: "boolean" },
|
|
790
|
+
timeout: { type: "string" },
|
|
791
|
+
port: { type: "string" },
|
|
792
|
+
"no-open": { type: "boolean" },
|
|
793
|
+
"no-inspect": { type: "boolean" },
|
|
794
|
+
dir: { type: "string" },
|
|
795
|
+
code: { type: "boolean" },
|
|
796
|
+
"no-wait": { type: "boolean" },
|
|
797
|
+
in: { type: "string" },
|
|
798
|
+
at: { type: "string" },
|
|
799
|
+
every: { type: "string" },
|
|
800
|
+
until: { type: "string" },
|
|
801
|
+
for: { type: "string" },
|
|
802
|
+
times: { type: "string" }
|
|
803
|
+
};
|
|
804
|
+
const MARK = "\0";
|
|
805
|
+
/**
|
|
806
|
+
* Attachment IDs are base64url, so boards minted some that start with `-`,
|
|
807
|
+
* which `parseArgs` reads as a flag. Where `download` takes its ID, a token
|
|
808
|
+
* shaped like one is the ID: this marks it to parse as a positional.
|
|
809
|
+
*/
|
|
810
|
+
function markAttachmentId(args) {
|
|
811
|
+
const positionals = [];
|
|
812
|
+
for (let i = 0; i < args.length; i++) {
|
|
813
|
+
const arg = args[i];
|
|
814
|
+
if (arg === "--") break;
|
|
815
|
+
if (!arg.startsWith("-") || arg === "-") positionals.push(arg);
|
|
816
|
+
else if (positionals.length === 1 && positionals[0] === "download" && AttachmentId.safeParse(arg).success) return [
|
|
817
|
+
...args.slice(0, i),
|
|
818
|
+
`${MARK}${arg}`,
|
|
819
|
+
...args.slice(i + 1)
|
|
820
|
+
];
|
|
821
|
+
else if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
|
|
822
|
+
}
|
|
823
|
+
return args;
|
|
824
|
+
}
|
|
825
|
+
/** Commands that are gone, and what to run instead. Their flags don't matter: an old hook's `vb update --skip` gets this too. */
|
|
826
|
+
function removedCommand(word) {
|
|
827
|
+
const cli = named();
|
|
828
|
+
const removed = {
|
|
829
|
+
update: `\`${cli} update\` is gone: reply in the thread you're working in (${cli} reply <id>), or post where it fits (${cli} post <channel>)`,
|
|
830
|
+
stuck: `\`${cli} stuck\` is gone: a board on a2() keeps its dead letters in A2, on the backend's \`servers\``
|
|
831
|
+
};
|
|
832
|
+
return Object.hasOwn(removed, word) ? removed[word] : void 0;
|
|
833
|
+
}
|
|
834
|
+
/** The command word: the first argument that isn't a flag or a string flag's value (a bare `--mentions` takes none). */
|
|
835
|
+
function commandWord(argv) {
|
|
836
|
+
const args = withMentions(argv);
|
|
837
|
+
for (let i = 0; i < args.length; i++) {
|
|
838
|
+
const arg = args[i];
|
|
839
|
+
if (arg === "--") return args[i + 1];
|
|
840
|
+
if (!arg.startsWith("-") || arg === "-") return arg;
|
|
841
|
+
if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const parseAll = (args) => parseArgs({
|
|
845
|
+
args,
|
|
846
|
+
allowPositionals: true,
|
|
847
|
+
options: OPTIONS
|
|
848
|
+
});
|
|
849
|
+
let app = {
|
|
850
|
+
name: "board",
|
|
851
|
+
commands: {}
|
|
852
|
+
};
|
|
853
|
+
let flags = {};
|
|
854
|
+
let positionals = [];
|
|
855
|
+
/** How messages name this CLI: `vb` for the standalone one, as they always have, or the app's own name. */
|
|
856
|
+
const named = () => app.name === "board" ? "vb" : app.name;
|
|
857
|
+
/** Parses `argv` for this run: false, once the error line is out, when it doesn't. */
|
|
858
|
+
function parse(argv) {
|
|
859
|
+
const args = withMentions(argv);
|
|
860
|
+
const removed = removedCommand(commandWord(args) ?? "");
|
|
861
|
+
try {
|
|
862
|
+
if (removed) throw new UsageError(removed);
|
|
863
|
+
const parsed = parseAll(markAttachmentId(args));
|
|
864
|
+
flags = parsed.values;
|
|
865
|
+
positionals = parsed.positionals.map((arg) => arg.startsWith(MARK) ? arg.slice(1) : arg);
|
|
866
|
+
return true;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
const unknown = error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && /'([^']+)'/.exec(error.message)?.[1];
|
|
869
|
+
io.error(terminalText(`error: ${unknown ? `unknown option: ${unknown} (run \`${app.name} help\`; text starting with - goes after --, as in -- "${unknown}")` : error.message}`) + "\n");
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const TERMINAL = {
|
|
874
|
+
write: (chunk) => void process.stdout.write(chunk),
|
|
875
|
+
error: (chunk) => void process.stderr.write(chunk),
|
|
876
|
+
async readFile(path) {
|
|
877
|
+
const info = await stat(path).catch(() => null);
|
|
878
|
+
if (!info?.isFile()) return null;
|
|
879
|
+
if (info.size > 104857600) throw new UsageError(`${path} is over ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB, the most an attachment can be`);
|
|
880
|
+
return new Uint8Array(await readFile(path));
|
|
881
|
+
},
|
|
882
|
+
writeFile: (path, data) => writeFile(path, data),
|
|
883
|
+
stdin: readStdin
|
|
884
|
+
};
|
|
885
|
+
let io = TERMINAL;
|
|
886
|
+
const out = (text) => io.write(`${terminalText(text)}\n`);
|
|
887
|
+
const printJson = (value) => io.write(`${JSON.stringify(value)}\n`);
|
|
888
|
+
/** A message's ID as you'd write it: `#812` or `812`. It's passed to the board as it is. */
|
|
889
|
+
function messageId(value, what = "message id") {
|
|
890
|
+
const id = value?.replace(/^#/, "") ?? "";
|
|
891
|
+
if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a ${what}, got: ${value ?? "(nothing)"}`);
|
|
892
|
+
return id;
|
|
893
|
+
}
|
|
894
|
+
/** Whether an argument names a message rather than a channel: `#<id>`, or a bare number. */
|
|
895
|
+
const isMessageRef = (value) => /^#[^\s/]+$/.test(value) || /^\d+$/.test(value);
|
|
896
|
+
function labelPairs(values) {
|
|
897
|
+
const pairs = {};
|
|
898
|
+
for (const value of values ?? []) {
|
|
899
|
+
const at = value.indexOf("=");
|
|
900
|
+
if (at <= 0) throw new UsageError(`labels look like key=value, got: ${value}`);
|
|
901
|
+
pairs[value.slice(0, at)] = value.slice(at + 1);
|
|
902
|
+
}
|
|
903
|
+
return pairs;
|
|
904
|
+
}
|
|
905
|
+
async function text(value) {
|
|
906
|
+
if (value === void 0) throw new UsageError("missing message text");
|
|
907
|
+
if (value !== "-") return value;
|
|
908
|
+
return (await io.stdin()).replace(/\n$/, "");
|
|
909
|
+
}
|
|
910
|
+
/** Uploads each `--attach` file, reading them all before sending any. */
|
|
911
|
+
async function attachments(board) {
|
|
912
|
+
const files = [];
|
|
913
|
+
for (const path of flags.attach ?? []) {
|
|
914
|
+
const data = await io.readFile(path);
|
|
915
|
+
if (!data) throw new UsageError(`no such file: ${path}`);
|
|
916
|
+
if (data.byteLength > 104857600) throw new UsageError(`${path} is over ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB, the most an attachment can be`);
|
|
917
|
+
files.push({
|
|
918
|
+
path,
|
|
919
|
+
data
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const uploaded = [];
|
|
923
|
+
for (const { path, data } of files) uploaded.push(await board.upload({
|
|
924
|
+
data: new Uint8Array(data),
|
|
925
|
+
name: basename(path)
|
|
926
|
+
}));
|
|
927
|
+
return uploaded;
|
|
928
|
+
}
|
|
929
|
+
/** `s12` or `12`. */
|
|
930
|
+
function scheduleId(value) {
|
|
931
|
+
const id = value?.replace(/^s(?=\d)/i, "") ?? "";
|
|
932
|
+
if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a schedule id like s12, got: ${value ?? "(nothing)"}`);
|
|
933
|
+
return id;
|
|
934
|
+
}
|
|
935
|
+
/** A time flag: anything `Date` reads, as ISO. */
|
|
936
|
+
function instant(flag, value) {
|
|
937
|
+
const time = new Date(value);
|
|
938
|
+
if (Number.isNaN(time.getTime())) throw new UsageError(`--${flag} takes a time like 2026-10-08T09:00:00Z, got: ${value}`);
|
|
939
|
+
return time.toISOString();
|
|
940
|
+
}
|
|
941
|
+
function filters(channel) {
|
|
942
|
+
return {
|
|
943
|
+
...channel ? { channel } : {},
|
|
944
|
+
...flags.re ? { re: messageId(flags.re) } : {},
|
|
945
|
+
...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
|
|
946
|
+
...flags.mentions ? { mentions: flags.mentions.replace(/^@/, "") } : {},
|
|
947
|
+
...flags.label?.length ? { label: flags.label } : {},
|
|
948
|
+
...flags.from ? { from: flags.from } : {},
|
|
949
|
+
...flags.text ? { text: flags.text } : {},
|
|
950
|
+
...flags.after !== void 0 ? { after: flags.after.replace(/^#/, "") } : {}
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* The board this command talks to, `--url`'s or the one the environment points at, as `--as`
|
|
955
|
+
* says: a handle on it, and how it reaches it (the credentials `vb` finds, `--handle`'s handle).
|
|
956
|
+
*/
|
|
957
|
+
/** The board the environment points at; with none, how to point at one, with the commands this app has. */
|
|
958
|
+
function configuredUrl() {
|
|
959
|
+
try {
|
|
960
|
+
return boardUrl();
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (!(error instanceof BoardError) || error.code !== "no_board") throw error;
|
|
963
|
+
const ways = [
|
|
964
|
+
`run \`${app.name} link <url>\``,
|
|
965
|
+
"set BOARD_URL",
|
|
966
|
+
`run \`${app.name} use <url>\``,
|
|
967
|
+
...app.commands.serve ? [`\`${app.name} serve\` for a local one`] : []
|
|
968
|
+
];
|
|
969
|
+
throw new BoardError("no_board", `no board URL: ${ways.slice(0, -1).join(", ")}, or ${ways.at(-1)}`, 0);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function connection(url = flags.url, options = {}) {
|
|
973
|
+
const at = (url ?? configuredUrl()).replace(/\/+$/, "");
|
|
974
|
+
const handle = options.handle ?? flags.handle;
|
|
975
|
+
const transport = connect(at, {
|
|
976
|
+
...options.token !== void 0 ? { token: options.token } : {},
|
|
977
|
+
...handle ? { handle } : {}
|
|
978
|
+
});
|
|
979
|
+
const board$1 = board({ backend: http(at, { fetch: transport.fetch }) });
|
|
980
|
+
const profile = flags.human ? null : flags.as ? flags.avatar ? {
|
|
981
|
+
name: flags.as,
|
|
982
|
+
avatar: flags.avatar
|
|
983
|
+
} : flags.as : void 0;
|
|
984
|
+
return {
|
|
985
|
+
url: at,
|
|
986
|
+
transport,
|
|
987
|
+
board: profile !== void 0 ? board$1.as(profile) : board$1
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/** A handle on the board this command talks to: `vb()`'s, as `--as` says, or the one `connection()` finds. */
|
|
991
|
+
function client() {
|
|
992
|
+
if (!io.board) return connection().board;
|
|
993
|
+
if (flags.human) return io.board.as(null);
|
|
994
|
+
if (flags.as) return io.board.as(flags.avatar ? {
|
|
995
|
+
name: flags.as,
|
|
996
|
+
avatar: flags.avatar
|
|
997
|
+
} : flags.as);
|
|
998
|
+
return io.board;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* What names an agent's turns, for the turn-end hook: its session, or for
|
|
1002
|
+
* Cursor, whose shell has no session ID, the project it works in.
|
|
1003
|
+
*/
|
|
1004
|
+
const turnKey = (agent, session) => session ?? (agent === "cursor" ? `cursor@${projectDir()}` : null);
|
|
1005
|
+
/** A write from an agent session counts as this turn's board update. */
|
|
1006
|
+
function wrote() {
|
|
1007
|
+
const detected = flags.human ? null : detectAgent();
|
|
1008
|
+
const key = detected ? turnKey(detected.profile, detected.session) : null;
|
|
1009
|
+
if (key) markPosted(key);
|
|
1010
|
+
}
|
|
1011
|
+
const fromNpx = (path) => /[\\/]_npx[\\/]/.test(path);
|
|
1012
|
+
/** The first `vb` on PATH outside npx's cache, which is gone once npx exits. */
|
|
1013
|
+
function globalVb() {
|
|
1014
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
1015
|
+
const candidate = join(dir, "vb");
|
|
1016
|
+
if (existsSync(candidate) && !fromNpx(realpathSync(candidate))) return candidate;
|
|
1017
|
+
}
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Whether `bin` runs the CLI at `self`: a symlink to it (npm), or a shell
|
|
1022
|
+
* shim that execs node on it (pnpm, through `$basedir`- or `$basedir_abs`-relative paths).
|
|
1023
|
+
*/
|
|
1024
|
+
function runsCli(bin, self) {
|
|
1025
|
+
try {
|
|
1026
|
+
const real = realpathSync(bin);
|
|
1027
|
+
if (real === self) return true;
|
|
1028
|
+
if (statSync(real).size > 65536) return false;
|
|
1029
|
+
const script = readFileSync(real, "utf8");
|
|
1030
|
+
if (!script.startsWith("#!")) return false;
|
|
1031
|
+
const basedir = dirname(bin);
|
|
1032
|
+
const paths = [...[...script.matchAll(/"([^"\n]+)"/g)].map(([, quoted]) => quoted.replace(/^\$(?:\{basedir(?:_abs)?\}|basedir(?:_abs)?)(?=\/)/, basedir)), ...[...script.matchAll(/^# cmd-shim-target=(.+)$/gm)].map(([, target]) => target.trim())];
|
|
1033
|
+
for (const path of paths) if (isAbsolute(path) && existsSync(path) && realpathSync(path) === self) return true;
|
|
1034
|
+
} catch {}
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* How an agent's hook should run this CLI, long after this command exits:
|
|
1039
|
+
* `vb` when that's this CLI; under npx, a global `vb` of this version, else
|
|
1040
|
+
* this version through npx; otherwise this CLI's full path.
|
|
1041
|
+
*/
|
|
1042
|
+
function cliCommand() {
|
|
1043
|
+
if (app.command) return { command: app.command };
|
|
1044
|
+
const self = realpathSync(process.argv[1]);
|
|
1045
|
+
const installed = globalVb();
|
|
1046
|
+
if (!fromNpx(self)) {
|
|
1047
|
+
if (installed && runsCli(installed, self)) return { command: "vb" };
|
|
1048
|
+
const quote = (part) => /^[\w./-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`;
|
|
1049
|
+
return { command: [process.execPath, self].map(quote).join(" ") };
|
|
1050
|
+
}
|
|
1051
|
+
let version = "";
|
|
1052
|
+
try {
|
|
1053
|
+
version = installed ? execFileSync(installed, ["--version"], {
|
|
1054
|
+
encoding: "utf8",
|
|
1055
|
+
timeout: 5e3,
|
|
1056
|
+
stdio: [
|
|
1057
|
+
"ignore",
|
|
1058
|
+
"pipe",
|
|
1059
|
+
"ignore"
|
|
1060
|
+
]
|
|
1061
|
+
}).trim() : "";
|
|
1062
|
+
} catch {}
|
|
1063
|
+
if (version === VERSION) return { command: "vb" };
|
|
1064
|
+
return {
|
|
1065
|
+
command: `npx -y @vercel/board@${VERSION}`,
|
|
1066
|
+
note: `the hook runs board ${VERSION} through npx${installed ? ` (your vb is ${version || "older"})` : ""}; \`npm i -g @vercel/board\` makes it start faster`
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* The board version a hook's CLI runs: pinned in an npx command, else what it
|
|
1071
|
+
* answers to `--version` (the first `vb` on PATH, for plain `vb`). Null when
|
|
1072
|
+
* it doesn't answer with one.
|
|
1073
|
+
*/
|
|
1074
|
+
function hookVersion(cli) {
|
|
1075
|
+
const pinned = /^npx -y @vercel\/board@(\S+)$/.exec(cli);
|
|
1076
|
+
if (pinned) return pinned[1];
|
|
1077
|
+
try {
|
|
1078
|
+
const answer = execFileSync("/bin/sh", ["-c", `${cli} --version`], {
|
|
1079
|
+
encoding: "utf8",
|
|
1080
|
+
timeout: 5e3,
|
|
1081
|
+
stdio: [
|
|
1082
|
+
"ignore",
|
|
1083
|
+
"pipe",
|
|
1084
|
+
"ignore"
|
|
1085
|
+
]
|
|
1086
|
+
}).trim();
|
|
1087
|
+
return /^\d+\.\d+\.\d+\S*$/.test(answer) ? answer : null;
|
|
1088
|
+
} catch {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Whether version `a` comes before `b`, by major, minor, and patch. */
|
|
1093
|
+
function olderVersion(a, b) {
|
|
1094
|
+
const parts = (version) => version.split(/[.-]/).slice(0, 3).map(Number);
|
|
1095
|
+
const [x, y] = [parts(a), parts(b)];
|
|
1096
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (x[i] ?? 0) < (y[i] ?? 0);
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
async function readStdin() {
|
|
1100
|
+
const chunks = [];
|
|
1101
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1102
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1103
|
+
}
|
|
1104
|
+
function hookAgents(names) {
|
|
1105
|
+
const known = Object.keys(AGENTS);
|
|
1106
|
+
for (const name of names) if (!known.includes(name)) throw new UsageError(`unknown agent: ${name} (known: ${known.join(", ")})`);
|
|
1107
|
+
return names.length ? names : known;
|
|
1108
|
+
}
|
|
1109
|
+
/** A write went through, but some handles in it matched no one: they stayed plain text. */
|
|
1110
|
+
function warnUnknownMentions(handles) {
|
|
1111
|
+
if (!handles?.length) return;
|
|
1112
|
+
const names = handles.map((handle) => `@${handle}`);
|
|
1113
|
+
const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
1114
|
+
const one = names.length === 1;
|
|
1115
|
+
io.error(terminalText(`warning: ${list} ${one ? "matches" : "match"} no one on the board, so ${one ? "it stays" : "they stay"} plain text and ${one ? "notifies" : "notify"} no one`) + "\n");
|
|
1116
|
+
}
|
|
1117
|
+
function printMessage(message) {
|
|
1118
|
+
if (flags.json) printJson(message);
|
|
1119
|
+
else out(String(message.id));
|
|
1120
|
+
}
|
|
1121
|
+
function printEvent(event) {
|
|
1122
|
+
if (flags.json) printJson(event);
|
|
1123
|
+
else out(formatEvent(event));
|
|
1124
|
+
}
|
|
1125
|
+
/** Best-effort: the URL is printed either way. */
|
|
1126
|
+
/** `url`, when it's an http or https URL as `new URL` reads it: the only kind the CLI opens, so a board can't hand the opener a flag or another scheme. */
|
|
1127
|
+
function browsable(url) {
|
|
1128
|
+
try {
|
|
1129
|
+
const parsed = new URL(url);
|
|
1130
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : null;
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/** Opens an http(s) URL in the browser, as one argument to the opener: no shell reads it, Windows' included. */
|
|
1136
|
+
function openBrowser(url) {
|
|
1137
|
+
const at = browsable(url);
|
|
1138
|
+
if (at === null) return;
|
|
1139
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", at] : process.platform === "win32" ? [
|
|
1140
|
+
"rundll32",
|
|
1141
|
+
"url.dll,FileProtocolHandler",
|
|
1142
|
+
at
|
|
1143
|
+
] : ["xdg-open", at];
|
|
1144
|
+
const child = spawn(command, args, {
|
|
1145
|
+
detached: true,
|
|
1146
|
+
stdio: "ignore"
|
|
1147
|
+
});
|
|
1148
|
+
child.on("error", () => {});
|
|
1149
|
+
child.unref();
|
|
1150
|
+
}
|
|
1151
|
+
/** Writes the board link: the project's `.board/` (kept out of Git), or the saved default when `root` is null. */
|
|
1152
|
+
async function saveLink(root, url, extra = {}) {
|
|
1153
|
+
const directory = root === null ? configDir() : join(root, ".board");
|
|
1154
|
+
if (root !== null) {
|
|
1155
|
+
const path = join(root, ".gitignore");
|
|
1156
|
+
const ignore = await readFile(path, "utf8").catch((error) => {
|
|
1157
|
+
if (error.code === "ENOENT") return "";
|
|
1158
|
+
throw error;
|
|
1159
|
+
});
|
|
1160
|
+
if (!ignore.split(/\r?\n/).some((line) => /^\/?\.board\/?$/.test(line))) await appendFile(path, `${ignore && !ignore.endsWith("\n") ? "\n" : ""}.board/\n`);
|
|
1161
|
+
}
|
|
1162
|
+
await mkdir(directory, {
|
|
1163
|
+
recursive: true,
|
|
1164
|
+
mode: 448
|
|
1165
|
+
});
|
|
1166
|
+
await writeFile(join(directory, "config.json"), `${JSON.stringify({
|
|
1167
|
+
url,
|
|
1168
|
+
...extra
|
|
1169
|
+
}, null, 2)}\n`, { mode: 384 });
|
|
1170
|
+
}
|
|
1171
|
+
/** Whether the project's link was written by `vb serve` (`"serve": true`), so a new run may move it. */
|
|
1172
|
+
function linkedByServe(root) {
|
|
1173
|
+
try {
|
|
1174
|
+
return JSON.parse(readFileSync(join(root, ".board", "config.json"), "utf8")).serve === true;
|
|
1175
|
+
} catch {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function portFlag() {
|
|
1180
|
+
const port = flags.port === void 0 ? void 0 : Number(flags.port);
|
|
1181
|
+
if (port !== void 0 && !(Number.isInteger(port) && port >= 0 && port <= 65535)) throw new UsageError(`expected a port, got: ${flags.port}`);
|
|
1182
|
+
return port;
|
|
1183
|
+
}
|
|
1184
|
+
/** How long `identity link` waits for the sign-in or the code. */
|
|
1185
|
+
const LINK_WAIT_MS = 6e5;
|
|
1186
|
+
async function identity(args) {
|
|
1187
|
+
const board = client();
|
|
1188
|
+
const [sub, target] = args;
|
|
1189
|
+
if (sub === void 0) {
|
|
1190
|
+
const view = await board.identity();
|
|
1191
|
+
if (flags.json) return printJson(view), 0;
|
|
1192
|
+
if (view.links.length === 0) out("no linked accounts");
|
|
1193
|
+
for (const link of view.links) out(`${link.handle ? `@${link.handle} ` : ""}${link.actor} by ${link.method === "code" ? "code" : "sign-in"} · ${link.at}`);
|
|
1194
|
+
if (view.unlinked?.length) out(`not linked ${view.unlinked.join(", ")} (${named()} identity link <key>)`);
|
|
1195
|
+
return 0;
|
|
1196
|
+
}
|
|
1197
|
+
if (sub === "unlink") {
|
|
1198
|
+
if (!target || args.length !== 2) throw new UsageError(`usage: ${app.name} identity unlink <account> (a handle or actor ID from \`${app.name} identity\`)`);
|
|
1199
|
+
const result = await board.unlinkAccount(target);
|
|
1200
|
+
if (flags.json) printJson(result);
|
|
1201
|
+
else out(`unlinked ${result.actor}`);
|
|
1202
|
+
return 0;
|
|
1203
|
+
}
|
|
1204
|
+
if (sub !== "link" || !target || args.length !== 2) throw new UsageError(`usage: ${app.name} identity [link <key> [--code] [--no-wait] | unlink <account>]`);
|
|
1205
|
+
const before = new Set((await board.identity()).links.map((link) => `${link.actor}|${link.at}`));
|
|
1206
|
+
const platform = (await board.discovery().catch(() => null))?.sync.find((entry) => entry.key === target)?.platform ?? target;
|
|
1207
|
+
const { code, expiresAt, signIn } = await board.syncCode(target);
|
|
1208
|
+
const consent = signIn !== void 0 && !flags.code;
|
|
1209
|
+
if (consent && browsable(signIn) === null) throw new Error(`the board's sign-in isn't an http or https URL: ${JSON.stringify(signIn)}`);
|
|
1210
|
+
if (flags.json) printJson({
|
|
1211
|
+
sync: target,
|
|
1212
|
+
platform,
|
|
1213
|
+
code,
|
|
1214
|
+
expiresAt,
|
|
1215
|
+
...signIn !== void 0 ? { signIn } : {}
|
|
1216
|
+
});
|
|
1217
|
+
const until = Date.now() + LINK_WAIT_MS;
|
|
1218
|
+
const sleep = () => new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
1219
|
+
/** An account linked on that platform since this run began: where the code or the sign-in landed. */
|
|
1220
|
+
const linkedSince = async () => (await board.identity()).links.find((link) => link.actor.startsWith(`${platform}:user:`) && !before.has(`${link.actor}|${link.at}`));
|
|
1221
|
+
if (consent) {
|
|
1222
|
+
if (!flags.json) {
|
|
1223
|
+
out(`Sign in to ${platform} as yourself to link it (and to post as you there):\n\n ${signIn}\n`);
|
|
1224
|
+
out(`Only open it yourself: whoever completes it links their account to you.`);
|
|
1225
|
+
}
|
|
1226
|
+
if (!flags["no-open"] && !flags.json) openBrowser(signIn);
|
|
1227
|
+
} else if (!flags.json) {
|
|
1228
|
+
const minutes = Math.round((Date.parse(expiresAt) - Date.now()) / 6e4);
|
|
1229
|
+
out(`From your ${platform} account, post this as a message anywhere the board syncs, within ${minutes} minutes:\n\n ${code}\n`);
|
|
1230
|
+
}
|
|
1231
|
+
if (flags["no-wait"]) return 0;
|
|
1232
|
+
while (Date.now() < Math.min(until, consent ? Infinity : Date.parse(expiresAt))) {
|
|
1233
|
+
await sleep();
|
|
1234
|
+
const linked = await linkedSince();
|
|
1235
|
+
if (linked) {
|
|
1236
|
+
if (flags.json) printJson(linked);
|
|
1237
|
+
else out(`linked ${linked.handle ? `@${linked.handle} ` : ""}(${linked.actor})`);
|
|
1238
|
+
return 0;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
io.error("timed out\n");
|
|
1242
|
+
return 124;
|
|
1243
|
+
}
|
|
1244
|
+
async function run(command, args) {
|
|
1245
|
+
switch (command) {
|
|
1246
|
+
case void 0:
|
|
1247
|
+
case "help":
|
|
1248
|
+
out(help());
|
|
1249
|
+
return 0;
|
|
1250
|
+
case "link":
|
|
1251
|
+
case "use": {
|
|
1252
|
+
const url = args[0];
|
|
1253
|
+
if (!url || args.length !== 1) throw new UsageError(`usage: ${app.name} ${command} <url>`);
|
|
1254
|
+
const discovery = await connection(url).board.discovery();
|
|
1255
|
+
const root = command === "link" ? projectDir() : null;
|
|
1256
|
+
await saveLink(root, discovery.url);
|
|
1257
|
+
out(root === null ? `using ${discovery.url}` : `linked ${root} to ${discovery.url}`);
|
|
1258
|
+
return 0;
|
|
1259
|
+
}
|
|
1260
|
+
case "whoami": {
|
|
1261
|
+
const { board, url, transport } = connection();
|
|
1262
|
+
const me = await board.me();
|
|
1263
|
+
const source = transport.source();
|
|
1264
|
+
if (flags.json) {
|
|
1265
|
+
printJson({
|
|
1266
|
+
...me,
|
|
1267
|
+
board: url,
|
|
1268
|
+
source
|
|
1269
|
+
});
|
|
1270
|
+
return 0;
|
|
1271
|
+
}
|
|
1272
|
+
const name = me.profile !== null ? `${me.name} via ${me.handle.split("/")[0]}` : me.name;
|
|
1273
|
+
out(`${name} (${me.actor})`);
|
|
1274
|
+
const rows = [["board", url]];
|
|
1275
|
+
if (source) rows.push(["source", source]);
|
|
1276
|
+
if (me.expiresAt) {
|
|
1277
|
+
const minutes = Math.max(0, Math.round((Date.parse(me.expiresAt) - Date.now()) / 6e4));
|
|
1278
|
+
rows.push(["token", `expires in ${Math.floor(minutes / 60)}h ${minutes % 60}m`]);
|
|
1279
|
+
}
|
|
1280
|
+
if (me.session) rows.push(["session", me.session]);
|
|
1281
|
+
(me.can === void 0 ? [] : formatAccess(me.can)).forEach((line, index) => rows.push([index === 0 ? "can" : "", line]));
|
|
1282
|
+
const width = Math.max(...rows.map(([subject]) => subject.length));
|
|
1283
|
+
for (const [subject, value] of rows) out(`${subject.padEnd(width)} ${value}`);
|
|
1284
|
+
return 0;
|
|
1285
|
+
}
|
|
1286
|
+
case "conformance": {
|
|
1287
|
+
if (args.length > 0) throw new UsageError(`usage: ${app.name} conformance [--channel <c>]`);
|
|
1288
|
+
const report = await conformance(client(), flags.channel ? { channel: flags.channel } : {});
|
|
1289
|
+
if (flags.json) printJson(report);
|
|
1290
|
+
else out(formatConformance(report));
|
|
1291
|
+
return report.ok ? 0 : 1;
|
|
1292
|
+
}
|
|
1293
|
+
case "channels": {
|
|
1294
|
+
const channels = await client().channels();
|
|
1295
|
+
if (flags.json) return channels.forEach(printJson), 0;
|
|
1296
|
+
if (channels.length === 0) return out("no channels yet"), 0;
|
|
1297
|
+
let group = "";
|
|
1298
|
+
for (const channel of channels) {
|
|
1299
|
+
const prefix = channel.path.includes("/") ? channel.path.slice(0, channel.path.lastIndexOf("/") + 1) : "";
|
|
1300
|
+
if (prefix !== group) {
|
|
1301
|
+
if (prefix) out(prefix);
|
|
1302
|
+
group = prefix;
|
|
1303
|
+
}
|
|
1304
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
1305
|
+
out(`${prefix ? " " : ""}${channel.path} ${count(channel.threads, "thread", "threads")} · ${count(channel.messages, "message", "messages")} · last #${channel.lastIndex}`);
|
|
1306
|
+
}
|
|
1307
|
+
return 0;
|
|
1308
|
+
}
|
|
1309
|
+
case "actors": {
|
|
1310
|
+
const actors = await client().actors();
|
|
1311
|
+
if (flags.json) return actors.forEach(printJson), 0;
|
|
1312
|
+
for (const actor of actors) out(`@${actor.handle} ${actor.name} ${actor.id}${actor.person ? ` → @${actor.person.handle}` : ""}`);
|
|
1313
|
+
return 0;
|
|
1314
|
+
}
|
|
1315
|
+
case "identity": return identity(args);
|
|
1316
|
+
case "read": {
|
|
1317
|
+
const target = args[0];
|
|
1318
|
+
const board = client();
|
|
1319
|
+
const link = target !== void 0 && /^https?:\/\//i.test(target);
|
|
1320
|
+
if (target && (link || isMessageRef(target))) {
|
|
1321
|
+
const thread = link ? await board.linked(target) : await board.thread(messageId(target));
|
|
1322
|
+
if (link && !Array.isArray(thread.replies)) throw new Error("this board can't read links: update board where the board is deployed");
|
|
1323
|
+
const narrowed = Object.keys(filters()).length > 0;
|
|
1324
|
+
const replies = narrowed ? (await board.messages({
|
|
1325
|
+
...filters(),
|
|
1326
|
+
re: thread.message.id,
|
|
1327
|
+
limit: 500
|
|
1328
|
+
})).messages.filter((m) => m.re !== null) : thread.replies;
|
|
1329
|
+
if (flags.json) return [thread.message, ...replies].forEach(printJson), 0;
|
|
1330
|
+
const authors = new Set([thread.message, ...replies].map((m) => m.by.actor));
|
|
1331
|
+
const unnamed = reactionNames([thread.message, ...replies]).filter((id) => !authors.has(id));
|
|
1332
|
+
const known = unnamed.length ? new Map((await board.actors().catch(() => [])).filter((a) => unnamed.includes(a.id)).map((a) => [a.id, actorName(a)])) : /* @__PURE__ */ new Map();
|
|
1333
|
+
out(formatThreads([thread.message, ...replies], known));
|
|
1334
|
+
if (!narrowed) for (const schedule of thread.schedules ?? []) out(formatSchedule(schedule, { pad: " " }));
|
|
1335
|
+
return 0;
|
|
1336
|
+
}
|
|
1337
|
+
const query = {
|
|
1338
|
+
...filters(target),
|
|
1339
|
+
...flags.limit ? { limit: Number(flags.limit) } : {}
|
|
1340
|
+
};
|
|
1341
|
+
const old = /* @__PURE__ */ new Error("this board can't list threads: update board where the board is deployed");
|
|
1342
|
+
const page = await board.threads(query).catch((error) => {
|
|
1343
|
+
throw error instanceof BoardError && error.status === 404 ? old : error;
|
|
1344
|
+
});
|
|
1345
|
+
if (!Array.isArray(page.threads)) throw old;
|
|
1346
|
+
if (!page.threads.length && target && !(await board.channels()).some((c) => c.path === target || c.path.startsWith(target.replace(/\/?$/, "/")))) throw new Error(`no channel ${target}: board channels lists them`);
|
|
1347
|
+
if (flags.json) return page.threads.forEach(printJson), 0;
|
|
1348
|
+
const count = `${page.threads.length} ${page.threads.length === 1 ? "thread" : "threads"}`;
|
|
1349
|
+
out(`${target ?? "all channels"} · ${count} · cursor ${page.cursor}${page.threads.length ? "\n" : ""}`);
|
|
1350
|
+
if (page.threads.length) out(formatThreadSummaries(page.threads, { channel: !target || target.endsWith("/") }));
|
|
1351
|
+
return 0;
|
|
1352
|
+
}
|
|
1353
|
+
case "post": {
|
|
1354
|
+
const [target, body] = args;
|
|
1355
|
+
if (!target) throw new UsageError(`usage: ${app.name} post <channel|id> "<text>"`);
|
|
1356
|
+
const re = isMessageRef(target) ? messageId(target) : void 0;
|
|
1357
|
+
if (re !== void 0 && flags.sync?.length) throw new UsageError(`--sync attaches whole threads: post to a channel, or use ${named()} sync <id> <target>`);
|
|
1358
|
+
if (flags.sync?.length && flags["no-sync"]) throw new UsageError("--sync and --no-sync contradict each other");
|
|
1359
|
+
const board = client();
|
|
1360
|
+
const content = body === void 0 && flags.attach?.length ? "" : await text(body);
|
|
1361
|
+
const files = await attachments(board);
|
|
1362
|
+
const options = {
|
|
1363
|
+
text: content,
|
|
1364
|
+
...files.length ? { attachments: files } : {},
|
|
1365
|
+
...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
|
|
1366
|
+
...flags.id ? { id: flags.id } : {},
|
|
1367
|
+
...flags["no-sync"] ? { noSync: true } : {}
|
|
1368
|
+
};
|
|
1369
|
+
const message = re === void 0 ? await board.post(target, {
|
|
1370
|
+
...options,
|
|
1371
|
+
...flags.sync?.length ? { sync: flags.sync } : {}
|
|
1372
|
+
}) : await board.reply(re, options);
|
|
1373
|
+
wrote();
|
|
1374
|
+
warnUnknownMentions(message.unknownMentions);
|
|
1375
|
+
printMessage(message);
|
|
1376
|
+
return 0;
|
|
1377
|
+
}
|
|
1378
|
+
case "reply": {
|
|
1379
|
+
const id = messageId(args[0]);
|
|
1380
|
+
const board = client();
|
|
1381
|
+
const content = args[1] === void 0 && flags.attach?.length ? "" : await text(args[1]);
|
|
1382
|
+
const files = await attachments(board);
|
|
1383
|
+
const message = await board.reply(id, {
|
|
1384
|
+
text: content,
|
|
1385
|
+
...files.length ? { attachments: files } : {},
|
|
1386
|
+
...flags.id ? { id: flags.id } : {},
|
|
1387
|
+
...flags["no-sync"] ? { noSync: true } : {}
|
|
1388
|
+
});
|
|
1389
|
+
wrote();
|
|
1390
|
+
warnUnknownMentions(message.unknownMentions);
|
|
1391
|
+
printMessage(message);
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
case "schedule": {
|
|
1395
|
+
if (args[0] === "cancel" && args.length === 2) {
|
|
1396
|
+
const { schedule, canceled } = await client().cancelSchedule(scheduleId(args[1]));
|
|
1397
|
+
if (flags.json) printJson(schedule);
|
|
1398
|
+
else out(canceled ? `canceled s${schedule.id}` : `s${schedule.id} is already ${schedule.state}`);
|
|
1399
|
+
return 0;
|
|
1400
|
+
}
|
|
1401
|
+
const [target, body] = args;
|
|
1402
|
+
if (!target) throw new UsageError(`usage: ${app.name} schedule <channel|id> "<text>" (--in 1d | --at <time> | --every 30m)`);
|
|
1403
|
+
if (!flags.in && !flags.at && !flags.every) throw new UsageError("say when: --in 1d, --at <time>, or --every 30m");
|
|
1404
|
+
if (flags.sync?.length) throw new UsageError("a schedule posts where you point it: --sync is for post");
|
|
1405
|
+
const times = flags.times === void 0 ? void 0 : Number(flags.times);
|
|
1406
|
+
if (times !== void 0 && !(Number.isInteger(times) && times > 0)) throw new UsageError(`--times takes a count, got: ${flags.times}`);
|
|
1407
|
+
const board = client();
|
|
1408
|
+
const content = body === void 0 && flags.attach?.length ? "" : await text(body);
|
|
1409
|
+
const files = await attachments(board);
|
|
1410
|
+
const where = isMessageRef(target) ? { re: messageId(target) } : target;
|
|
1411
|
+
const schedule = await board.schedule(where, {
|
|
1412
|
+
text: content,
|
|
1413
|
+
...files.length ? { attachments: files } : {},
|
|
1414
|
+
...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
|
|
1415
|
+
...flags["no-sync"] ? { noSync: true } : {},
|
|
1416
|
+
...flags.in ? { in: flags.in } : {},
|
|
1417
|
+
...flags.at ? { at: instant("at", flags.at) } : {},
|
|
1418
|
+
...flags.every ? { every: flags.every } : {},
|
|
1419
|
+
...flags.until ? { until: instant("until", flags.until) } : {},
|
|
1420
|
+
...flags.for ? { for: flags.for } : {},
|
|
1421
|
+
...times !== void 0 ? { times } : {}
|
|
1422
|
+
});
|
|
1423
|
+
wrote();
|
|
1424
|
+
warnUnknownMentions(schedule.unknownMentions);
|
|
1425
|
+
if (flags.json) printJson(schedule);
|
|
1426
|
+
else out(formatSchedule(schedule));
|
|
1427
|
+
return 0;
|
|
1428
|
+
}
|
|
1429
|
+
case "schedules": {
|
|
1430
|
+
const list = await client().schedules({
|
|
1431
|
+
...args[0] ? { re: messageId(args[0]) } : {},
|
|
1432
|
+
...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
|
|
1433
|
+
...flags.channel ? { channel: flags.channel } : {},
|
|
1434
|
+
...flags.all ? { all: true } : {}
|
|
1435
|
+
});
|
|
1436
|
+
if (flags.json) return list.forEach(printJson), 0;
|
|
1437
|
+
if (list.length === 0) return out(flags.all ? "no schedules" : "no pending schedules"), 0;
|
|
1438
|
+
out(list.map((schedule) => formatSchedule(schedule)).join("\n"));
|
|
1439
|
+
return 0;
|
|
1440
|
+
}
|
|
1441
|
+
case "hooks": {
|
|
1442
|
+
const [action = "status", ...names] = args;
|
|
1443
|
+
const agents = hookAgents(names);
|
|
1444
|
+
const width = Math.max(...agents.map((agent) => AGENTS[agent].name.length));
|
|
1445
|
+
const row = (agent, ...cells) => out([AGENTS[agent].name.padEnd(width), ...cells].join(" ").trimEnd());
|
|
1446
|
+
if (action === "status") {
|
|
1447
|
+
const versions = /* @__PURE__ */ new Map();
|
|
1448
|
+
for (const agent of agents) {
|
|
1449
|
+
const installed = installedHook(agent);
|
|
1450
|
+
if (installed === null) {
|
|
1451
|
+
row(agent, "not installed");
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const cli = cliOf(installed);
|
|
1455
|
+
const problems = outdated(agent, installed);
|
|
1456
|
+
if (!versions.has(cli)) versions.set(cli, app.command ? null : hookVersion(cli));
|
|
1457
|
+
const version = versions.get(cli);
|
|
1458
|
+
const behind = version != null && olderVersion(version, VERSION);
|
|
1459
|
+
if (behind) problems.push(`runs board ${version}, not ${VERSION}`);
|
|
1460
|
+
row(agent, (problems.length ? "outdated" : "installed").padEnd(9), tilde(AGENTS[agent].file()));
|
|
1461
|
+
if (problems.length === 0) continue;
|
|
1462
|
+
for (const problem of problems) out(` ${problem}`);
|
|
1463
|
+
const global = behind && cli === "vb";
|
|
1464
|
+
const fixes = [...global ? ["npm i -g @vercel/board"] : [], ...problems.length > (global ? 1 : 0) ? [`${named()} hooks install ${agent}`] : []];
|
|
1465
|
+
out(` fix with ${fixes.join(", then ")}`);
|
|
1466
|
+
}
|
|
1467
|
+
return 0;
|
|
1468
|
+
}
|
|
1469
|
+
if (action === "uninstall") {
|
|
1470
|
+
for (const agent of agents) {
|
|
1471
|
+
const installed = installedHook(agent);
|
|
1472
|
+
const cli = installed ? cliOf(installed) : null;
|
|
1473
|
+
const rules = cli ? allowRules(agent, cli) : null;
|
|
1474
|
+
if (rules) disallow(rules.file, [...rules.rules, ...legacyRules(agent, cli)]);
|
|
1475
|
+
row(agent, uninstallHook(agent) ? "removed" : "not installed", installed ? tilde(AGENTS[agent].file()) : "");
|
|
1476
|
+
}
|
|
1477
|
+
return 0;
|
|
1478
|
+
}
|
|
1479
|
+
if (action !== "install") throw new UsageError(`usage: ${app.name} hooks [install|uninstall|status] [claude|codex|cursor|pi]...`);
|
|
1480
|
+
const { command: cli, note } = cliCommand();
|
|
1481
|
+
for (const agent of agents) {
|
|
1482
|
+
const had = installedHook(agent) !== null;
|
|
1483
|
+
const changed = installHook(agent, cli);
|
|
1484
|
+
const rules = allowRules(agent, cli);
|
|
1485
|
+
const added = rules ? allow(rules.file, rules.rules) : [];
|
|
1486
|
+
const dropped = rules ? disallow(rules.file, legacyRules(agent, cli)) : false;
|
|
1487
|
+
out(`${AGENTS[agent].name} ${tilde(AGENTS[agent].file())}`);
|
|
1488
|
+
out(` ${!had ? "added the turn-end hook" : changed || added.length > 0 || dropped ? "updated" : "already installed"}`);
|
|
1489
|
+
if (added.length > 0) out(` ${agent === "claude" ? "reply, post, read, and channels run without asking" : `${named()} runs without asking`}`);
|
|
1490
|
+
if (agent === "cursor" && !rules && !app.command) out(" npm i -g @vercel/board lets Cursor run vb without asking");
|
|
1491
|
+
if (agent === "codex") {
|
|
1492
|
+
if (changed) out(" approve it once in Codex with /hooks");
|
|
1493
|
+
let host = null;
|
|
1494
|
+
try {
|
|
1495
|
+
const url = process.env.BOARD_URL || projectUrl();
|
|
1496
|
+
host = url ? new URL(url).hostname : null;
|
|
1497
|
+
} catch {}
|
|
1498
|
+
const config = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
|
|
1499
|
+
const profile = codexNetwork(host);
|
|
1500
|
+
out(` to let ${named()} reach the board from Codex's sandbox, add to ${tilde(config)}:`);
|
|
1501
|
+
out("");
|
|
1502
|
+
out([
|
|
1503
|
+
"# a permission profile (beta) that opens the network to just the board",
|
|
1504
|
+
"",
|
|
1505
|
+
"# at the top, before any [table]",
|
|
1506
|
+
profile.top,
|
|
1507
|
+
"",
|
|
1508
|
+
"# anywhere (merge [features] into yours)",
|
|
1509
|
+
profile.tables
|
|
1510
|
+
].join("\n").replace(/^(?=.)/gm, " "));
|
|
1511
|
+
}
|
|
1512
|
+
out("");
|
|
1513
|
+
}
|
|
1514
|
+
if (note) out(`${note[0].toUpperCase()}${note.slice(1)}.`);
|
|
1515
|
+
out("The hooks remind once per turn without a board update.");
|
|
1516
|
+
out(`They stay quiet outside projects linked with \`${named()} link <url>\`.`);
|
|
1517
|
+
return 0;
|
|
1518
|
+
}
|
|
1519
|
+
case "hook": {
|
|
1520
|
+
if (args[0] !== "stop") throw new UsageError("usage: board hook stop --agent <agent> (run by an agent's turn-end hook)");
|
|
1521
|
+
const agent = flags.agent ?? "agent";
|
|
1522
|
+
try {
|
|
1523
|
+
const payload = JSON.parse(await readStdin());
|
|
1524
|
+
const cwd = payload.cwd ?? payload.workspace_roots?.[0];
|
|
1525
|
+
if (cwd && existsSync(cwd)) process.chdir(cwd);
|
|
1526
|
+
const session = turnKey(agent, agent === "cursor" ? null : payload.session_id ?? null);
|
|
1527
|
+
const boardUrl = process.env.BOARD_URL || projectUrl();
|
|
1528
|
+
if (!session || !boardUrl) return 0;
|
|
1529
|
+
if (payload.status && payload.status !== "completed") return 0;
|
|
1530
|
+
if (decideStop(session, payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0) === "ask") {
|
|
1531
|
+
const cli = cliCommand().command;
|
|
1532
|
+
const text = claimLinkOffer(boardUrl) ? `${reminder(cli)}\n\n${linkOffer(cli)}` : reminder(cli);
|
|
1533
|
+
printJson(stopOutput(agent, text));
|
|
1534
|
+
}
|
|
1535
|
+
} catch {}
|
|
1536
|
+
return 0;
|
|
1537
|
+
}
|
|
1538
|
+
case "edit": {
|
|
1539
|
+
const { unknownMentions } = await client().edit(messageId(args[0]), await text(args[1]));
|
|
1540
|
+
warnUnknownMentions(unknownMentions);
|
|
1541
|
+
return 0;
|
|
1542
|
+
}
|
|
1543
|
+
case "delete":
|
|
1544
|
+
await client().delete(messageId(args[0]));
|
|
1545
|
+
return 0;
|
|
1546
|
+
case "react": {
|
|
1547
|
+
const id = messageId(args[0]);
|
|
1548
|
+
if (!args[1]) throw new UsageError(`usage: ${app.name} react <id> <emoji> [--remove]`);
|
|
1549
|
+
let emoji;
|
|
1550
|
+
try {
|
|
1551
|
+
emoji = normalizeEmoji(args[1]);
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
throw new UsageError(error.message);
|
|
1554
|
+
}
|
|
1555
|
+
await client().react(id, emoji, flags.remove ? { remove: true } : {});
|
|
1556
|
+
return 0;
|
|
1557
|
+
}
|
|
1558
|
+
case "label": {
|
|
1559
|
+
const id = messageId(args[0]);
|
|
1560
|
+
const set = labelPairs(args.slice(1));
|
|
1561
|
+
const unset = flags.unset ?? [];
|
|
1562
|
+
if (Object.keys(set).length === 0 && unset.length === 0) throw new UsageError(`usage: ${app.name} label <id> k=v... [--unset k]...`);
|
|
1563
|
+
await client().label(id, {
|
|
1564
|
+
set,
|
|
1565
|
+
unset
|
|
1566
|
+
});
|
|
1567
|
+
return 0;
|
|
1568
|
+
}
|
|
1569
|
+
case "typing": {
|
|
1570
|
+
const id = messageId(args[0]);
|
|
1571
|
+
const status = args.slice(1).join(" ").trim();
|
|
1572
|
+
const targets = await client().typing(id, status || void 0);
|
|
1573
|
+
if (flags.json) printJson({ targets });
|
|
1574
|
+
return 0;
|
|
1575
|
+
}
|
|
1576
|
+
case "sync": {
|
|
1577
|
+
const id = messageId(args[0]);
|
|
1578
|
+
if (!args[1]) {
|
|
1579
|
+
const { targets } = await client().sync(id);
|
|
1580
|
+
if (flags.json) return targets.forEach(printJson), 0;
|
|
1581
|
+
out(targets.length ? formatSynced(targets) : "not synced anywhere");
|
|
1582
|
+
return 0;
|
|
1583
|
+
}
|
|
1584
|
+
const result = await client().sync(id, args[1]);
|
|
1585
|
+
if (flags.json) printJson(result);
|
|
1586
|
+
else out(result.attached ? `synced with ${result.target}` : `already synced with ${result.target}`);
|
|
1587
|
+
return 0;
|
|
1588
|
+
}
|
|
1589
|
+
case "download": {
|
|
1590
|
+
const id = args[0];
|
|
1591
|
+
if (!id) throw new UsageError(`usage: ${app.name} download <attachment> [path] (the id \`${app.name} read\` shows on a file line)`);
|
|
1592
|
+
const response = await client().download(id);
|
|
1593
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
1594
|
+
if (args[1] === "-") return io.write(data), 0;
|
|
1595
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
1596
|
+
const named = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1];
|
|
1597
|
+
const path = args[1] ?? basename(named ? decodeURIComponent(named) : id);
|
|
1598
|
+
await io.writeFile(path, data);
|
|
1599
|
+
out(path);
|
|
1600
|
+
return 0;
|
|
1601
|
+
}
|
|
1602
|
+
case "inbox": {
|
|
1603
|
+
const key = args[0];
|
|
1604
|
+
if (!key) throw new UsageError(`usage: ${app.name} inbox <key> [channel] [--limit N]`);
|
|
1605
|
+
const events = await client().inbox(key, {
|
|
1606
|
+
...args[1] ? { channel: args[1] } : {},
|
|
1607
|
+
...flags.limit ? { limit: Number(flags.limit) } : {}
|
|
1608
|
+
});
|
|
1609
|
+
if (flags.json) return events.forEach(printJson), 0;
|
|
1610
|
+
if (events.length === 0) return out("no events"), 0;
|
|
1611
|
+
for (const entry of events) {
|
|
1612
|
+
const event = entry.event;
|
|
1613
|
+
const detail = event.text ?? event.type ?? event.emoji ?? "";
|
|
1614
|
+
out(`${entry.createdAt} ${entry.channel} ${event.kind} ${event.author?.handle ?? ""} ${detail.split("\n")[0]}`);
|
|
1615
|
+
}
|
|
1616
|
+
return 0;
|
|
1617
|
+
}
|
|
1618
|
+
case "sources": {
|
|
1619
|
+
const [key, sub, target] = args;
|
|
1620
|
+
if (!key) {
|
|
1621
|
+
const { sync } = await client().discovery();
|
|
1622
|
+
if (flags.json) return sync.forEach(printJson), 0;
|
|
1623
|
+
if (sync.length === 0) return out("no sources: this board has no sync configured"), 0;
|
|
1624
|
+
for (const entry of sync) out(`${entry.key} ${entry.platform}`);
|
|
1625
|
+
return 0;
|
|
1626
|
+
}
|
|
1627
|
+
if (sub === "join" && target) {
|
|
1628
|
+
const joined = await client().joinSource(key, target);
|
|
1629
|
+
if (flags.json) return printJson(joined), 0;
|
|
1630
|
+
const label = joined.name ? `#${joined.name}` : joined.channel;
|
|
1631
|
+
return out(joined.joined ? `joined ${label}` : `already in ${label}`), 0;
|
|
1632
|
+
}
|
|
1633
|
+
if (sub !== "read" || !target) throw new UsageError(`usage: ${app.name} sources <key> read <channel|thread|link> [--limit N], or sources <key> join <channel|link>`);
|
|
1634
|
+
const read = await client().readSource(key, target, flags.limit ? { limit: Number(flags.limit) } : {});
|
|
1635
|
+
if (flags.json) return printJson(read), 0;
|
|
1636
|
+
return out(formatSourceRead(key, read, Date.now(), named())), 0;
|
|
1637
|
+
}
|
|
1638
|
+
case "tail": {
|
|
1639
|
+
const controller = new AbortController();
|
|
1640
|
+
const release = stopOn(controller);
|
|
1641
|
+
try {
|
|
1642
|
+
for await (const event of client().events({
|
|
1643
|
+
...filters(args[0]),
|
|
1644
|
+
...flags.all ? { all: true } : {}
|
|
1645
|
+
}, { signal: controller.signal })) printEvent(event);
|
|
1646
|
+
} finally {
|
|
1647
|
+
release();
|
|
1648
|
+
}
|
|
1649
|
+
return 0;
|
|
1650
|
+
}
|
|
1651
|
+
case "wait": {
|
|
1652
|
+
const controller = new AbortController();
|
|
1653
|
+
const release = stopOn(controller);
|
|
1654
|
+
const event = await client().wait(filters(args[0]), {
|
|
1655
|
+
timeout: flags.timeout ?? "30m",
|
|
1656
|
+
signal: controller.signal
|
|
1657
|
+
}).finally(release);
|
|
1658
|
+
if (!event) {
|
|
1659
|
+
if (!controller.signal.aborted) io.error("timed out\n");
|
|
1660
|
+
return 124;
|
|
1661
|
+
}
|
|
1662
|
+
printEvent(event);
|
|
1663
|
+
return 0;
|
|
1664
|
+
}
|
|
1665
|
+
default: {
|
|
1666
|
+
const own = app.commands[command];
|
|
1667
|
+
if (own) return own.run(args, context());
|
|
1668
|
+
throw new UsageError(`unknown command: ${command} (run \`${app.name} help\`)`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/** Aborts `controller` on the run's signal, or Ctrl-C without one; the returned function stops listening. */
|
|
1673
|
+
function stopOn(controller) {
|
|
1674
|
+
const stop = () => controller.abort();
|
|
1675
|
+
if (io.signal) {
|
|
1676
|
+
if (io.signal.aborted) stop();
|
|
1677
|
+
io.signal.addEventListener("abort", stop, { once: true });
|
|
1678
|
+
const signal = io.signal;
|
|
1679
|
+
return () => signal.removeEventListener("abort", stop);
|
|
1680
|
+
}
|
|
1681
|
+
process.once("SIGINT", stop);
|
|
1682
|
+
return () => process.off("SIGINT", stop);
|
|
1683
|
+
}
|
|
1684
|
+
/** What a command an app adds gets: this run's flags, and the helpers board's own commands use. */
|
|
1685
|
+
const context = () => ({
|
|
1686
|
+
flags,
|
|
1687
|
+
out,
|
|
1688
|
+
usage: (message) => new UsageError(message),
|
|
1689
|
+
portFlag,
|
|
1690
|
+
connection,
|
|
1691
|
+
client,
|
|
1692
|
+
openBrowser,
|
|
1693
|
+
saveLink,
|
|
1694
|
+
linkedByServe
|
|
1695
|
+
});
|
|
1696
|
+
/**
|
|
1697
|
+
* Runs board's CLI on `argv` (what follows the command name) as `app`, and answers its exit
|
|
1698
|
+
* code: 0 success, 1 error, 2 misuse, 124 when `wait` times out. Errors are one `error: …` line
|
|
1699
|
+
* on stderr. A long-running command (`tail`, `serve`) answers once it's stopped.
|
|
1700
|
+
*/
|
|
1701
|
+
async function main(argv, options) {
|
|
1702
|
+
app = options;
|
|
1703
|
+
if (!parse(argv)) return 2;
|
|
1704
|
+
if (flags.version) {
|
|
1705
|
+
out(VERSION);
|
|
1706
|
+
return 0;
|
|
1707
|
+
}
|
|
1708
|
+
if (flags.help) {
|
|
1709
|
+
out(help());
|
|
1710
|
+
return 0;
|
|
1711
|
+
}
|
|
1712
|
+
const [command, ...args] = positionals;
|
|
1713
|
+
try {
|
|
1714
|
+
if (io.board) {
|
|
1715
|
+
if (flags.url || flags.handle) throw new UsageError(`--${flags.url ? "url" : "handle"} doesn't apply: the board handle you pass says which board and who`);
|
|
1716
|
+
if (command !== void 0 && MACHINE.has(command)) throw new UsageError(`${command} isn't available in-process: it's about the machine vb runs on`);
|
|
1717
|
+
}
|
|
1718
|
+
return await run(command, args);
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
io.error(`${terminalText(`error: ${error.message}`)}\n`);
|
|
1721
|
+
return error instanceof UsageError ? 2 : 1;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/** The commands about the machine the CLI runs on, not the board: none of them run in-process. */
|
|
1725
|
+
const MACHINE = /* @__PURE__ */ new Set([
|
|
1726
|
+
"link",
|
|
1727
|
+
"use",
|
|
1728
|
+
"whoami",
|
|
1729
|
+
"identity",
|
|
1730
|
+
"hooks",
|
|
1731
|
+
"hook",
|
|
1732
|
+
"serve",
|
|
1733
|
+
"inspect"
|
|
1734
|
+
]);
|
|
1735
|
+
/** `main()` with `io` in place of the terminal, for `vb()`. One run at a time, as `main()`'s. */
|
|
1736
|
+
async function mainWith(argv, options, with_) {
|
|
1737
|
+
io = with_;
|
|
1738
|
+
try {
|
|
1739
|
+
return await main(argv, options);
|
|
1740
|
+
} finally {
|
|
1741
|
+
io = TERMINAL;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
//#endregion
|
|
1745
|
+
export { who as i, main as n, mainWith as r, commandWord as t };
|