@vercel/board 0.20.0 → 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.
Files changed (31) hide show
  1. package/README.md +0 -25
  2. package/dist/{a2-D__qQpe7.mjs → a2-BgNAyr_f.mjs} +1 -1
  3. package/dist/backend-a2.mjs +1 -1
  4. package/dist/{backend-http-CG5y03UP.mjs → backend-http-CLcXkcAU.mjs} +190 -140
  5. package/dist/backend-http-Dzb4lpMG.mjs +18 -0
  6. package/dist/backend-http.mjs +1 -1
  7. package/dist/backend-http.node.mjs +1 -1
  8. package/dist/backend-vercel-BjUpoLR9.mjs +60 -0
  9. package/dist/backend-vercel-CYOqtLPc.d.mts +656 -0
  10. package/dist/backend-vercel-DjL8tYIF.mjs +1314 -0
  11. package/dist/backend-vercel.d.mts +9 -0
  12. package/dist/backend-vercel.mjs +11 -0
  13. package/dist/backend-vercel.node.d.mts +12 -0
  14. package/dist/backend-vercel.node.mjs +2 -0
  15. package/dist/cli-embedded.d.mts +5 -5
  16. package/dist/cli-embedded.mjs +6 -5
  17. package/dist/cli.mjs +3 -3
  18. package/dist/{exports-DqWJ7nzC.mjs → exports-DIivm0je.mjs} +1 -1
  19. package/dist/{index.default-C3luVnd-.mjs → index.default-DNKzR4md.mjs} +1 -1
  20. package/dist/index.mjs +2 -2
  21. package/dist/index.node.mjs +1 -1
  22. package/dist/inspect/app.js +29 -29
  23. package/dist/{main-wB-VfTCL.mjs → main-BZ7nOIhV.mjs} +75 -508
  24. package/dist/{backend-http-c-VHpUOs.mjs → node-BeKPcokJ.mjs} +15 -17
  25. package/dist/{serve-AnApUWOR.mjs → serve-8zkBGMmb.mjs} +3 -3
  26. package/dist/{server-BqVickb5.mjs → server-Du0rSpcW.mjs} +2 -2
  27. package/dist/{watchers-hash-DYwvkhdX.mjs → watchers-hash-9sxfL1Gd.mjs} +1 -1
  28. package/docs/03-guides/01-coding-agents.md +0 -94
  29. package/docs/04-reference/01-cli.md +26 -23
  30. package/docs/index.md +1 -2
  31. 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-DYwvkhdX.mjs";
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 { a as detectAgent, c as projectUrl, i as connect, n as boardUrl, r as configDir, s as projectDir, t as http } from "./backend-http-c-VHpUOs.mjs";
7
- import { t as board } from "./server-BqVickb5.mjs";
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 { execFileSync, spawn } from "node:child_process";
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 { 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";
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 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`).
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: REMINDED.map((sub) => `Bash(${cli} ${sub}:*)`)
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 [install|uninstall|status] [claude|codex|cursor|pi]...
742
- after a turn that didn't update the board, remind the agent to (linked projects only)
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"
@@ -831,8 +552,9 @@ function removedCommand(word) {
831
552
  };
832
553
  return Object.hasOwn(removed, word) ? removed[word] : void 0;
833
554
  }
834
- /** The command word: the first argument that isn't a flag or a string flag's value. */
835
- function commandWord(args) {
555
+ /** The command word: the first argument that isn't a flag or a string flag's value (a bare `--mentions` takes none). */
556
+ function commandWord(argv) {
557
+ const args = withMentions(argv);
836
558
  for (let i = 0; i < args.length; i++) {
837
559
  const arg = args[i];
838
560
  if (arg === "--") return args[i + 1];
@@ -892,6 +614,8 @@ function messageId(value, what = "message id") {
892
614
  }
893
615
  /** Whether an argument names a message rather than a channel: `#<id>`, or a bare number. */
894
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}`;
895
619
  function labelPairs(values) {
896
620
  const pairs = {};
897
621
  for (const value of values ?? []) {
@@ -950,15 +674,15 @@ function filters(channel) {
950
674
  };
951
675
  }
952
676
  /**
953
- * The board this command talks to, `--url`'s or the one the environment points at, as `--as`
954
- * says: a handle on it, and how it reaches it (the credentials `vb` finds, `--handle`'s handle).
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.
955
679
  */
956
- /** The board the environment points at; with none, how to point at one, with the commands this app has. */
957
680
  function configuredUrl() {
958
681
  try {
959
682
  return boardUrl();
960
683
  } catch (error) {
961
684
  if (!(error instanceof BoardError) || error.code !== "no_board") throw error;
685
+ if (app.teamBoard) return null;
962
686
  const ways = [
963
687
  `run \`${app.name} link <url>\``,
964
688
  "set BOARD_URL",
@@ -968,22 +692,44 @@ function configuredUrl() {
968
692
  throw new BoardError("no_board", `no board URL: ${ways.slice(0, -1).join(", ")}, or ${ways.at(-1)}`, 0);
969
693
  }
970
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
+ */
971
710
  function connection(url = flags.url, options = {}) {
972
- const at = (url ?? configuredUrl()).replace(/\/+$/, "");
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(/\/+$/, "");
973
723
  const handle = options.handle ?? flags.handle;
974
724
  const transport = connect(at, {
975
725
  ...options.token !== void 0 ? { token: options.token } : {},
976
726
  ...handle ? { handle } : {}
977
727
  });
978
- const board$1 = board({ backend: http(at, { fetch: transport.fetch }) });
979
- const profile = flags.human ? null : flags.as ? flags.avatar ? {
980
- name: flags.as,
981
- avatar: flags.avatar
982
- } : flags.as : void 0;
728
+ const board$2 = board({ backend: http(at, { fetch: transport.fetch }) });
983
729
  return {
984
730
  url: at,
985
731
  transport,
986
- board: profile !== void 0 ? board$1.as(profile) : board$1
732
+ board: profile !== void 0 ? board$2.as(profile) : board$2
987
733
  };
988
734
  }
989
735
  /** A handle on the board this command talks to: `vb()`'s, as `--as` says, or the one `connection()` finds. */
@@ -996,105 +742,6 @@ function client() {
996
742
  } : flags.as);
997
743
  return io.board;
998
744
  }
999
- /**
1000
- * What names an agent's turns, for the turn-end hook: its session, or for
1001
- * Cursor, whose shell has no session ID, the project it works in.
1002
- */
1003
- const turnKey = (agent, session) => session ?? (agent === "cursor" ? `cursor@${projectDir()}` : null);
1004
- /** A write from an agent session counts as this turn's board update. */
1005
- function wrote() {
1006
- const detected = flags.human ? null : detectAgent();
1007
- const key = detected ? turnKey(detected.profile, detected.session) : null;
1008
- if (key) markPosted(key);
1009
- }
1010
- const fromNpx = (path) => /[\\/]_npx[\\/]/.test(path);
1011
- /** The first `vb` on PATH outside npx's cache, which is gone once npx exits. */
1012
- function globalVb() {
1013
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
1014
- const candidate = join(dir, "vb");
1015
- if (existsSync(candidate) && !fromNpx(realpathSync(candidate))) return candidate;
1016
- }
1017
- return null;
1018
- }
1019
- /**
1020
- * Whether `bin` runs the CLI at `self`: a symlink to it (npm), or a shell
1021
- * shim that execs node on it (pnpm, through `$basedir`- or `$basedir_abs`-relative paths).
1022
- */
1023
- function runsCli(bin, self) {
1024
- try {
1025
- const real = realpathSync(bin);
1026
- if (real === self) return true;
1027
- if (statSync(real).size > 65536) return false;
1028
- const script = readFileSync(real, "utf8");
1029
- if (!script.startsWith("#!")) return false;
1030
- const basedir = dirname(bin);
1031
- 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())];
1032
- for (const path of paths) if (isAbsolute(path) && existsSync(path) && realpathSync(path) === self) return true;
1033
- } catch {}
1034
- return false;
1035
- }
1036
- /**
1037
- * How an agent's hook should run this CLI, long after this command exits:
1038
- * `vb` when that's this CLI; under npx, a global `vb` of this version, else
1039
- * this version through npx; otherwise this CLI's full path.
1040
- */
1041
- function cliCommand() {
1042
- if (app.command) return { command: app.command };
1043
- const self = realpathSync(process.argv[1]);
1044
- const installed = globalVb();
1045
- if (!fromNpx(self)) {
1046
- if (installed && runsCli(installed, self)) return { command: "vb" };
1047
- const quote = (part) => /^[\w./-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`;
1048
- return { command: [process.execPath, self].map(quote).join(" ") };
1049
- }
1050
- let version = "";
1051
- try {
1052
- version = installed ? execFileSync(installed, ["--version"], {
1053
- encoding: "utf8",
1054
- timeout: 5e3,
1055
- stdio: [
1056
- "ignore",
1057
- "pipe",
1058
- "ignore"
1059
- ]
1060
- }).trim() : "";
1061
- } catch {}
1062
- if (version === VERSION) return { command: "vb" };
1063
- return {
1064
- command: `npx -y @vercel/board@${VERSION}`,
1065
- note: `the hook runs board ${VERSION} through npx${installed ? ` (your vb is ${version || "older"})` : ""}; \`npm i -g @vercel/board\` makes it start faster`
1066
- };
1067
- }
1068
- /**
1069
- * The board version a hook's CLI runs: pinned in an npx command, else what it
1070
- * answers to `--version` (the first `vb` on PATH, for plain `vb`). Null when
1071
- * it doesn't answer with one.
1072
- */
1073
- function hookVersion(cli) {
1074
- const pinned = /^npx -y @vercel\/board@(\S+)$/.exec(cli);
1075
- if (pinned) return pinned[1];
1076
- try {
1077
- const answer = execFileSync("/bin/sh", ["-c", `${cli} --version`], {
1078
- encoding: "utf8",
1079
- timeout: 5e3,
1080
- stdio: [
1081
- "ignore",
1082
- "pipe",
1083
- "ignore"
1084
- ]
1085
- }).trim();
1086
- return /^\d+\.\d+\.\d+\S*$/.test(answer) ? answer : null;
1087
- } catch {
1088
- return null;
1089
- }
1090
- }
1091
- /** Whether version `a` comes before `b`, by major, minor, and patch. */
1092
- function olderVersion(a, b) {
1093
- const parts = (version) => version.split(/[.-]/).slice(0, 3).map(Number);
1094
- const [x, y] = [parts(a), parts(b)];
1095
- for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (x[i] ?? 0) < (y[i] ?? 0);
1096
- return false;
1097
- }
1098
745
  async function readStdin() {
1099
746
  const chunks = [];
1100
747
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -1115,7 +762,7 @@ function warnUnknownMentions(handles) {
1115
762
  }
1116
763
  function printMessage(message) {
1117
764
  if (flags.json) printJson(message);
1118
- else out(String(message.id));
765
+ else out(messageRef(message.id));
1119
766
  }
1120
767
  function printEvent(event) {
1121
768
  if (flags.json) printJson(event);
@@ -1257,8 +904,9 @@ async function run(command, args) {
1257
904
  return 0;
1258
905
  }
1259
906
  case "whoami": {
1260
- const { board, url, transport } = connection();
907
+ const { board, url: given, transport } = connection();
1261
908
  const me = await board.me();
909
+ const url = given ?? (await board.discovery()).url;
1262
910
  const source = transport.source();
1263
911
  if (flags.json) {
1264
912
  printJson({
@@ -1369,7 +1017,6 @@ async function run(command, args) {
1369
1017
  ...options,
1370
1018
  ...flags.sync?.length ? { sync: flags.sync } : {}
1371
1019
  }) : await board.reply(re, options);
1372
- wrote();
1373
1020
  warnUnknownMentions(message.unknownMentions);
1374
1021
  printMessage(message);
1375
1022
  return 0;
@@ -1385,7 +1032,6 @@ async function run(command, args) {
1385
1032
  ...flags.id ? { id: flags.id } : {},
1386
1033
  ...flags["no-sync"] ? { noSync: true } : {}
1387
1034
  });
1388
- wrote();
1389
1035
  warnUnknownMentions(message.unknownMentions);
1390
1036
  printMessage(message);
1391
1037
  return 0;
@@ -1419,7 +1065,6 @@ async function run(command, args) {
1419
1065
  ...flags.for ? { for: flags.for } : {},
1420
1066
  ...times !== void 0 ? { times } : {}
1421
1067
  });
1422
- wrote();
1423
1068
  warnUnknownMentions(schedule.unknownMentions);
1424
1069
  if (flags.json) printJson(schedule);
1425
1070
  else out(formatSchedule(schedule));
@@ -1438,102 +1083,24 @@ async function run(command, args) {
1438
1083
  return 0;
1439
1084
  }
1440
1085
  case "hooks": {
1441
- const [action = "status", ...names] = args;
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`);
1442
1088
  const agents = hookAgents(names);
1443
1089
  const width = Math.max(...agents.map((agent) => AGENTS[agent].name.length));
1444
- const row = (agent, ...cells) => out([AGENTS[agent].name.padEnd(width), ...cells].join(" ").trimEnd());
1445
- if (action === "status") {
1446
- const versions = /* @__PURE__ */ new Map();
1447
- for (const agent of agents) {
1448
- const installed = installedHook(agent);
1449
- if (installed === null) {
1450
- row(agent, "not installed");
1451
- continue;
1452
- }
1453
- const cli = cliOf(installed);
1454
- const problems = outdated(agent, installed);
1455
- if (!versions.has(cli)) versions.set(cli, app.command ? null : hookVersion(cli));
1456
- const version = versions.get(cli);
1457
- const behind = version != null && olderVersion(version, VERSION);
1458
- if (behind) problems.push(`runs board ${version}, not ${VERSION}`);
1459
- row(agent, (problems.length ? "outdated" : "installed").padEnd(9), tilde(AGENTS[agent].file()));
1460
- if (problems.length === 0) continue;
1461
- for (const problem of problems) out(` ${problem}`);
1462
- const global = behind && cli === "vb";
1463
- const fixes = [...global ? ["npm i -g @vercel/board"] : [], ...problems.length > (global ? 1 : 0) ? [`${named()} hooks install ${agent}`] : []];
1464
- out(` fix with ${fixes.join(", then ")}`);
1465
- }
1466
- return 0;
1467
- }
1468
- if (action === "uninstall") {
1469
- for (const agent of agents) {
1470
- const installed = installedHook(agent);
1471
- const cli = installed ? cliOf(installed) : null;
1472
- const rules = cli ? allowRules(agent, cli) : null;
1473
- if (rules) disallow(rules.file, [...rules.rules, ...legacyRules(agent, cli)]);
1474
- row(agent, uninstallHook(agent) ? "removed" : "not installed", installed ? tilde(AGENTS[agent].file()) : "");
1475
- }
1476
- return 0;
1477
- }
1478
- if (action !== "install") throw new UsageError(`usage: ${app.name} hooks [install|uninstall|status] [claude|codex|cursor|pi]...`);
1479
- const { command: cli, note } = cliCommand();
1480
1090
  for (const agent of agents) {
1481
- const had = installedHook(agent) !== null;
1482
- const changed = installHook(agent, cli);
1483
- const rules = allowRules(agent, cli);
1484
- const added = rules ? allow(rules.file, rules.rules) : [];
1485
- const dropped = rules ? disallow(rules.file, legacyRules(agent, cli)) : false;
1486
- out(`${AGENTS[agent].name} ${tilde(AGENTS[agent].file())}`);
1487
- out(` ${!had ? "added the turn-end hook" : changed || added.length > 0 || dropped ? "updated" : "already installed"}`);
1488
- if (added.length > 0) out(` ${agent === "claude" ? "reply, post, read, and channels run without asking" : `${named()} runs without asking`}`);
1489
- if (agent === "cursor" && !rules && !app.command) out(" npm i -g @vercel/board lets Cursor run vb without asking");
1490
- if (agent === "codex") {
1491
- if (changed) out(" approve it once in Codex with /hooks");
1492
- let host = null;
1493
- try {
1494
- const url = process.env.BOARD_URL || projectUrl();
1495
- host = url ? new URL(url).hostname : null;
1496
- } catch {}
1497
- const config = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
1498
- const profile = codexNetwork(host);
1499
- out(` to let ${named()} reach the board from Codex's sandbox, add to ${tilde(config)}:`);
1500
- out("");
1501
- out([
1502
- "# a permission profile (beta) that opens the network to just the board",
1503
- "",
1504
- "# at the top, before any [table]",
1505
- profile.top,
1506
- "",
1507
- "# anywhere (merge [features] into yours)",
1508
- profile.tables
1509
- ].join("\n").replace(/^(?=.)/gm, " "));
1510
- }
1511
- 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());
1512
1100
  }
1513
- if (note) out(`${note[0].toUpperCase()}${note.slice(1)}.`);
1514
- out("The hooks remind once per turn without a board update.");
1515
- out(`They stay quiet outside projects linked with \`${named()} link <url>\`.`);
1516
- return 0;
1517
- }
1518
- case "hook": {
1519
- if (args[0] !== "stop") throw new UsageError("usage: board hook stop --agent <agent> (run by an agent's turn-end hook)");
1520
- const agent = flags.agent ?? "agent";
1521
- try {
1522
- const payload = JSON.parse(await readStdin());
1523
- const cwd = payload.cwd ?? payload.workspace_roots?.[0];
1524
- if (cwd && existsSync(cwd)) process.chdir(cwd);
1525
- const session = turnKey(agent, agent === "cursor" ? null : payload.session_id ?? null);
1526
- const boardUrl = process.env.BOARD_URL || projectUrl();
1527
- if (!session || !boardUrl) return 0;
1528
- if (payload.status && payload.status !== "completed") return 0;
1529
- if (decideStop(session, payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0) === "ask") {
1530
- const cli = cliCommand().command;
1531
- const text = claimLinkOffer(boardUrl) ? `${reminder(cli)}\n\n${linkOffer(cli)}` : reminder(cli);
1532
- printJson(stopOutput(agent, text));
1533
- }
1534
- } catch {}
1535
1101
  return 0;
1536
1102
  }
1103
+ case "hook": return 0;
1537
1104
  case "edit": {
1538
1105
  const { unknownMentions } = await client().edit(messageId(args[0]), await text(args[1]));
1539
1106
  warnUnknownMentions(unknownMentions);