@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.
- package/README.md +0 -25
- package/dist/{a2-D__qQpe7.mjs → a2-BgNAyr_f.mjs} +1 -1
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-CG5y03UP.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 +5 -5
- package/dist/cli-embedded.mjs +6 -5
- package/dist/cli.mjs +3 -3
- package/dist/{exports-DqWJ7nzC.mjs → exports-DIivm0je.mjs} +1 -1
- package/dist/{index.default-C3luVnd-.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-wB-VfTCL.mjs → main-BZ7nOIhV.mjs} +75 -508
- package/dist/{backend-http-c-VHpUOs.mjs → node-BeKPcokJ.mjs} +15 -17
- package/dist/{serve-AnApUWOR.mjs → serve-8zkBGMmb.mjs} +3 -3
- package/dist/{server-BqVickb5.mjs → server-Du0rSpcW.mjs} +2 -2
- package/dist/{watchers-hash-DYwvkhdX.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"
|
|
@@ -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(
|
|
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
|
|
954
|
-
*
|
|
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
|
|
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$
|
|
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$
|
|
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(
|
|
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
|
|
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
|
|
1482
|
-
const
|
|
1483
|
-
const rules = allowRules(agent, cli);
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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);
|