@vercel/board 0.20.1 → 0.21.0
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/README.md +0 -25
- package/dist/{a2-QtmN9EhW.mjs → a2-BgNAyr_f.mjs} +1 -1
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-CbcjkHEn.mjs → backend-http-CLcXkcAU.mjs} +190 -140
- package/dist/backend-http-Dzb4lpMG.mjs +18 -0
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/backend-vercel-BjUpoLR9.mjs +60 -0
- package/dist/backend-vercel-CYOqtLPc.d.mts +656 -0
- package/dist/backend-vercel-DjL8tYIF.mjs +1314 -0
- package/dist/backend-vercel.d.mts +9 -0
- package/dist/backend-vercel.mjs +11 -0
- package/dist/backend-vercel.node.d.mts +12 -0
- package/dist/backend-vercel.node.mjs +2 -0
- package/dist/cli-embedded.d.mts +3 -3
- package/dist/cli-embedded.mjs +6 -5
- package/dist/cli.mjs +3 -3
- package/dist/{exports-Dd8YdgO-.mjs → exports-DIivm0je.mjs} +1 -1
- package/dist/{index.default-BqstUHvz.mjs → index.default-DNKzR4md.mjs} +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.node.mjs +1 -1
- package/dist/inspect/app.js +29 -29
- package/dist/{main-CRLa7i3I.mjs → main-BZ7nOIhV.mjs} +72 -506
- package/dist/{backend-http-maHy-JW3.mjs → node-BeKPcokJ.mjs} +15 -17
- package/dist/{serve-1kBFSHe_.mjs → serve-8zkBGMmb.mjs} +3 -3
- package/dist/{server-CuqhG9E6.mjs → server-Du0rSpcW.mjs} +2 -2
- package/dist/{watchers-hash-Cd6ZLo9m.mjs → watchers-hash-9sxfL1Gd.mjs} +1 -1
- package/docs/03-guides/01-coding-agents.md +0 -94
- package/docs/04-reference/01-cli.md +26 -23
- package/docs/index.md +1 -2
- package/package.json +6 -1
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { i as fileSize, n as DIRECT_UPLOAD_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
2
2
|
import { a as AttachmentId } from "./schemas-Be08nyfW.mjs";
|
|
3
|
-
import { a as withMentions, f as VERSION, i as FILTER_FLAGS } from "./watchers-hash-
|
|
3
|
+
import { a as withMentions, f as VERSION, i as FILTER_FLAGS } from "./watchers-hash-9sxfL1Gd.mjs";
|
|
4
4
|
import { n as displayEmoji, r as normalizeEmoji } from "./emoji-CTLpvH-A.mjs";
|
|
5
5
|
import { t as BoardError } from "./client-ClznIBMg.mjs";
|
|
6
|
-
import {
|
|
7
|
-
import { t as
|
|
6
|
+
import { n as configDir, o as projectDir, r as connect, t as boardUrl } from "./node-BeKPcokJ.mjs";
|
|
7
|
+
import { t as http } from "./backend-http-Dzb4lpMG.mjs";
|
|
8
|
+
import { t as board } from "./server-Du0rSpcW.mjs";
|
|
8
9
|
import { t as conformance } from "./conformance-DQ9qVzWP.mjs";
|
|
10
|
+
import { n as vercel, t as credentialSource } from "./backend-vercel-BjUpoLR9.mjs";
|
|
9
11
|
import { t as displayText } from "./bots-CGkO76qH.mjs";
|
|
10
|
-
import {
|
|
11
|
-
import { createHash } from "node:crypto";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
12
13
|
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
13
|
-
import {
|
|
14
|
-
import { homedir
|
|
15
|
-
import { basename,
|
|
14
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
16
17
|
import { parseArgs } from "node:util";
|
|
17
18
|
//#region src/cli/format.ts
|
|
18
19
|
/** "claude via julian" for a profile, the name otherwise. */
|
|
@@ -336,94 +337,6 @@ function without(entries, shape) {
|
|
|
336
337
|
}
|
|
337
338
|
/** The line of our Pi extension that holds the command it runs: how status finds it. */
|
|
338
339
|
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
340
|
/** Removes our hook. A file that held only it (Cursor's, which we created) goes away with it. */
|
|
428
341
|
function uninstallHook(agent) {
|
|
429
342
|
const spec = AGENTS[agent];
|
|
@@ -461,26 +374,19 @@ function tilde(path) {
|
|
|
461
374
|
/** The CLI an installed hook runs: everything before ` hook stop`. */
|
|
462
375
|
const cliOf = (command) => command.slice(0, command.indexOf(" hook stop "));
|
|
463
376
|
/**
|
|
464
|
-
* The
|
|
465
|
-
*
|
|
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`).
|
|
377
|
+
* The allow rules an install added, so the reminded commands ran without a prompt: Claude Code's
|
|
378
|
+
* command prefixes (`vb update` from the oldest installs), and Cursor's `vb` on PATH.
|
|
469
379
|
*/
|
|
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
380
|
function allowRules(agent, cli) {
|
|
481
381
|
if (agent === "claude") return {
|
|
482
382
|
file: AGENTS.claude.file(),
|
|
483
|
-
rules:
|
|
383
|
+
rules: [
|
|
384
|
+
"reply",
|
|
385
|
+
"post",
|
|
386
|
+
"read",
|
|
387
|
+
"channels",
|
|
388
|
+
"update"
|
|
389
|
+
].map((sub) => `Bash(${cli} ${sub}:*)`)
|
|
484
390
|
};
|
|
485
391
|
if (agent === "cursor" && cli === "vb") return {
|
|
486
392
|
file: join(homedir(), ".cursor", "cli-config.json"),
|
|
@@ -488,20 +394,6 @@ function allowRules(agent, cli) {
|
|
|
488
394
|
};
|
|
489
395
|
return null;
|
|
490
396
|
}
|
|
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
397
|
/** Removes the rules a file has. Answers whether it removed any. */
|
|
506
398
|
function disallow(file, rules) {
|
|
507
399
|
const config = readConfig(file);
|
|
@@ -516,179 +408,6 @@ function disallow(file, rules) {
|
|
|
516
408
|
});
|
|
517
409
|
return true;
|
|
518
410
|
}
|
|
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
411
|
//#endregion
|
|
693
412
|
//#region src/cli/main.ts
|
|
694
413
|
const help = () => `${app.name} ${VERSION}: a message board you mount in one route
|
|
@@ -738,15 +457,15 @@ usage: ${app.name} <command> [options]${app.name === "board" ? " (also inst
|
|
|
738
457
|
conformance [--channel <c>] check that the board speaks the protocol: posts to conformance,
|
|
739
458
|
kept on the board, and deletes what it posted (1 if a check fails)
|
|
740
459
|
|
|
741
|
-
hooks
|
|
742
|
-
|
|
460
|
+
hooks uninstall [claude|codex|cursor|pi]...
|
|
461
|
+
remove the turn-end hooks an earlier board installed
|
|
743
462
|
|
|
744
463
|
tail [channel] [filters] [--all] live, until Ctrl-C
|
|
745
464
|
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
465
|
|
|
747
466
|
filters: --re <id> --by <handle> --from <source> --label k[=v|w] --text <words> --mentions [@handle] --after <id>
|
|
748
467
|
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
|
|
468
|
+
global: --url <url> --scope <team> --as <profile> --avatar <url> --human --handle <h> --json
|
|
750
469
|
json: one object per line (JSONL): { message, replies, last } for read <channel>, a message for read <id>
|
|
751
470
|
text: pass "-" to read it from stdin; it may be left out when you --attach
|
|
752
471
|
env: BOARD_URL BOARD_TOKEN BOARD_AS BOARD_AVATAR BOARD_HANDLE`;
|
|
@@ -759,6 +478,8 @@ const OPTIONS = {
|
|
|
759
478
|
human: { type: "boolean" },
|
|
760
479
|
handle: { type: "string" },
|
|
761
480
|
json: { type: "boolean" },
|
|
481
|
+
scope: { type: "string" },
|
|
482
|
+
team: { type: "string" },
|
|
762
483
|
help: {
|
|
763
484
|
type: "boolean",
|
|
764
485
|
short: "h"
|
|
@@ -893,6 +614,8 @@ function messageId(value, what = "message id") {
|
|
|
893
614
|
}
|
|
894
615
|
/** Whether an argument names a message rather than a channel: `#<id>`, or a bare number. */
|
|
895
616
|
const isMessageRef = (value) => /^#[^\s/]+$/.test(value) || /^\d+$/.test(value);
|
|
617
|
+
/** A message's ID as it's passed back: bare when it's a number, `#<id>` when it could be a channel's name. */
|
|
618
|
+
const messageRef = (id) => /^\d+$/.test(id) ? id : `#${id}`;
|
|
896
619
|
function labelPairs(values) {
|
|
897
620
|
const pairs = {};
|
|
898
621
|
for (const value of values ?? []) {
|
|
@@ -951,15 +674,15 @@ function filters(channel) {
|
|
|
951
674
|
};
|
|
952
675
|
}
|
|
953
676
|
/**
|
|
954
|
-
* The board
|
|
955
|
-
*
|
|
677
|
+
* The board the environment points at. With none, the team's board for an app that has one
|
|
678
|
+
* (null), or else how to point at one, with the commands this app has.
|
|
956
679
|
*/
|
|
957
|
-
/** The board the environment points at; with none, how to point at one, with the commands this app has. */
|
|
958
680
|
function configuredUrl() {
|
|
959
681
|
try {
|
|
960
682
|
return boardUrl();
|
|
961
683
|
} catch (error) {
|
|
962
684
|
if (!(error instanceof BoardError) || error.code !== "no_board") throw error;
|
|
685
|
+
if (app.teamBoard) return null;
|
|
963
686
|
const ways = [
|
|
964
687
|
`run \`${app.name} link <url>\``,
|
|
965
688
|
"set BOARD_URL",
|
|
@@ -969,22 +692,44 @@ function configuredUrl() {
|
|
|
969
692
|
throw new BoardError("no_board", `no board URL: ${ways.slice(0, -1).join(", ")}, or ${ways.at(-1)}`, 0);
|
|
970
693
|
}
|
|
971
694
|
}
|
|
695
|
+
/** Who the handle writes as: `--as`'s profile (with `--avatar`), yourself with `--human`, or what the environment says. */
|
|
696
|
+
function profileFlag() {
|
|
697
|
+
if (flags.human) return null;
|
|
698
|
+
if (!flags.as) return void 0;
|
|
699
|
+
return flags.avatar ? {
|
|
700
|
+
name: flags.as,
|
|
701
|
+
avatar: flags.avatar
|
|
702
|
+
} : flags.as;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The board this command talks to, as `--as` says: a handle on it, where it is (null until its
|
|
706
|
+
* discovery says, for a team's board), and how it reaches it. It's `--url`'s; a team's board in
|
|
707
|
+
* Vercel's API for `--scope` or `--team`; the one the environment points at; or, for an app with
|
|
708
|
+
* a team board, the linked project's team's, or your login's.
|
|
709
|
+
*/
|
|
972
710
|
function connection(url = flags.url, options = {}) {
|
|
973
|
-
const
|
|
711
|
+
const profile = profileFlag();
|
|
712
|
+
const team = flags.scope ?? flags.team;
|
|
713
|
+
const configured = url ?? (team === void 0 ? configuredUrl() : null);
|
|
714
|
+
if (configured === null) {
|
|
715
|
+
const board$1 = board({ backend: vercel(team !== void 0 ? { team } : {}) });
|
|
716
|
+
return {
|
|
717
|
+
url: null,
|
|
718
|
+
transport: { source: credentialSource },
|
|
719
|
+
board: profile !== void 0 ? board$1.as(profile) : board$1
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
const at = configured.replace(/\/+$/, "");
|
|
974
723
|
const handle = options.handle ?? flags.handle;
|
|
975
724
|
const transport = connect(at, {
|
|
976
725
|
...options.token !== void 0 ? { token: options.token } : {},
|
|
977
726
|
...handle ? { handle } : {}
|
|
978
727
|
});
|
|
979
|
-
const board$
|
|
980
|
-
const profile = flags.human ? null : flags.as ? flags.avatar ? {
|
|
981
|
-
name: flags.as,
|
|
982
|
-
avatar: flags.avatar
|
|
983
|
-
} : flags.as : void 0;
|
|
728
|
+
const board$2 = board({ backend: http(at, { fetch: transport.fetch }) });
|
|
984
729
|
return {
|
|
985
730
|
url: at,
|
|
986
731
|
transport,
|
|
987
|
-
board: profile !== void 0 ? board$
|
|
732
|
+
board: profile !== void 0 ? board$2.as(profile) : board$2
|
|
988
733
|
};
|
|
989
734
|
}
|
|
990
735
|
/** A handle on the board this command talks to: `vb()`'s, as `--as` says, or the one `connection()` finds. */
|
|
@@ -997,105 +742,6 @@ function client() {
|
|
|
997
742
|
} : flags.as);
|
|
998
743
|
return io.board;
|
|
999
744
|
}
|
|
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
745
|
async function readStdin() {
|
|
1100
746
|
const chunks = [];
|
|
1101
747
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -1116,7 +762,7 @@ function warnUnknownMentions(handles) {
|
|
|
1116
762
|
}
|
|
1117
763
|
function printMessage(message) {
|
|
1118
764
|
if (flags.json) printJson(message);
|
|
1119
|
-
else out(
|
|
765
|
+
else out(messageRef(message.id));
|
|
1120
766
|
}
|
|
1121
767
|
function printEvent(event) {
|
|
1122
768
|
if (flags.json) printJson(event);
|
|
@@ -1258,8 +904,9 @@ async function run(command, args) {
|
|
|
1258
904
|
return 0;
|
|
1259
905
|
}
|
|
1260
906
|
case "whoami": {
|
|
1261
|
-
const { board, url, transport } = connection();
|
|
907
|
+
const { board, url: given, transport } = connection();
|
|
1262
908
|
const me = await board.me();
|
|
909
|
+
const url = given ?? (await board.discovery()).url;
|
|
1263
910
|
const source = transport.source();
|
|
1264
911
|
if (flags.json) {
|
|
1265
912
|
printJson({
|
|
@@ -1370,7 +1017,6 @@ async function run(command, args) {
|
|
|
1370
1017
|
...options,
|
|
1371
1018
|
...flags.sync?.length ? { sync: flags.sync } : {}
|
|
1372
1019
|
}) : await board.reply(re, options);
|
|
1373
|
-
wrote();
|
|
1374
1020
|
warnUnknownMentions(message.unknownMentions);
|
|
1375
1021
|
printMessage(message);
|
|
1376
1022
|
return 0;
|
|
@@ -1386,7 +1032,6 @@ async function run(command, args) {
|
|
|
1386
1032
|
...flags.id ? { id: flags.id } : {},
|
|
1387
1033
|
...flags["no-sync"] ? { noSync: true } : {}
|
|
1388
1034
|
});
|
|
1389
|
-
wrote();
|
|
1390
1035
|
warnUnknownMentions(message.unknownMentions);
|
|
1391
1036
|
printMessage(message);
|
|
1392
1037
|
return 0;
|
|
@@ -1420,7 +1065,6 @@ async function run(command, args) {
|
|
|
1420
1065
|
...flags.for ? { for: flags.for } : {},
|
|
1421
1066
|
...times !== void 0 ? { times } : {}
|
|
1422
1067
|
});
|
|
1423
|
-
wrote();
|
|
1424
1068
|
warnUnknownMentions(schedule.unknownMentions);
|
|
1425
1069
|
if (flags.json) printJson(schedule);
|
|
1426
1070
|
else out(formatSchedule(schedule));
|
|
@@ -1439,102 +1083,24 @@ async function run(command, args) {
|
|
|
1439
1083
|
return 0;
|
|
1440
1084
|
}
|
|
1441
1085
|
case "hooks": {
|
|
1442
|
-
const [action
|
|
1086
|
+
const [action, ...names] = args;
|
|
1087
|
+
if (action !== "uninstall") throw new UsageError(`\`${`${named()} hooks ${action ?? ""}`.trimEnd()}\` is gone: turn-end hooks were removed. Run \`${named()} hooks uninstall\` to clear an earlier install`);
|
|
1443
1088
|
const agents = hookAgents(names);
|
|
1444
1089
|
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
1090
|
for (const agent of agents) {
|
|
1482
|
-
const
|
|
1483
|
-
const
|
|
1484
|
-
const rules = allowRules(agent, cli);
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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("");
|
|
1091
|
+
const installed = installedHook(agent);
|
|
1092
|
+
const cli = installed ? cliOf(installed) : null;
|
|
1093
|
+
const rules = cli ? allowRules(agent, cli) : null;
|
|
1094
|
+
if (rules) disallow(rules.file, rules.rules);
|
|
1095
|
+
out([
|
|
1096
|
+
AGENTS[agent].name.padEnd(width),
|
|
1097
|
+
uninstallHook(agent) ? "removed" : "not installed",
|
|
1098
|
+
installed ? tilde(AGENTS[agent].file()) : ""
|
|
1099
|
+
].join(" ").trimEnd());
|
|
1513
1100
|
}
|
|
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
1101
|
return 0;
|
|
1537
1102
|
}
|
|
1103
|
+
case "hook": return 0;
|
|
1538
1104
|
case "edit": {
|
|
1539
1105
|
const { unknownMentions } = await client().edit(messageId(args[0]), await text(args[1]));
|
|
1540
1106
|
warnUnknownMentions(unknownMentions);
|