@botbuddy/cli 1.67.0 → 1.68.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/CHANGELOG.md +6 -0
- package/claude-plugin/.claude-plugin/marketplace.json +17 -0
- package/claude-plugin/bb-status/.claude-plugin/plugin.json +9 -0
- package/claude-plugin/bb-status/README.md +156 -0
- package/claude-plugin/bb-status/hooks/hooks.json +1 -0
- package/claude-plugin/bb-status/hooks/register.js +992 -0
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +7 -2
- package/src/commands.mjs +16 -1
- package/src/status-band-read.mjs +501 -0
- package/src/status-band-relay.mjs +104 -0
- package/src/status-band.mjs +126 -0
- package/src/update-check.mjs +15 -1
- package/src/wait-core.mjs +3 -0
- package/src/wait.mjs +3 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// BOT-3135 / BOT-3152. The band's relay child (`bb status-band relay`): arms ONE
|
|
2
|
+
// `bb wait` on the band's own subjects and reports how it ended. It runs this
|
|
3
|
+
// installed package's own `wait` through the node that runs it — no PATH lookup,
|
|
4
|
+
// no shell, no repo checkout — and never reads BB itself.
|
|
5
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
|
|
10
|
+
// Under the 10-minute ceiling a mod gives a one-shot child, so a stuck
|
|
11
|
+
// relay can never outlive one arm; the mod re-arms from the cursor.
|
|
12
|
+
export const WAIT_TIMEOUT_SECONDS = 570;
|
|
13
|
+
const SHORT_TIMEOUT_REASON =
|
|
14
|
+
"bb-status band relay: re-armed from its cursor every 570 s by the mod";
|
|
15
|
+
const KEY = "(?:BOT|ENT)-[1-9]\\d{0,5}";
|
|
16
|
+
const REPO = "[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}";
|
|
17
|
+
// BOT-3136: the epic row wakes on its Linear project.
|
|
18
|
+
const PROJECT = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
19
|
+
// BOT-3152 AC-2: any staging incident of the session's tenant opening or closing.
|
|
20
|
+
const STAGING = "event:type=staging_incident";
|
|
21
|
+
const CONDITION = new RegExp(
|
|
22
|
+
`^(?:linear:issue=${KEY}|unblocked:${KEY}|pr-(?:state|review):repo=${REPO},pr=[1-9]\\d{0,6}|event:type=linear_project,subject=${PROJECT}|${STAGING})$`,
|
|
23
|
+
);
|
|
24
|
+
const CURSOR = /^\d{1,16}$/;
|
|
25
|
+
// The band's largest list: 5 tickets, 5 PRs (state + review), blockers, epic, staging.
|
|
26
|
+
const MAX_CONDITIONS = 18;
|
|
27
|
+
export const CLI_ENTRY = join(resolve(dirname(fileURLToPath(import.meta.url)), ".."), "bin", "botbuddy.mjs");
|
|
28
|
+
|
|
29
|
+
function invalid() {
|
|
30
|
+
throw new Error("invalid_arguments");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function relayArgs(argv) {
|
|
34
|
+
const conditions = [];
|
|
35
|
+
let since = null;
|
|
36
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
37
|
+
const [flag, value] = [argv[i], argv[i + 1]];
|
|
38
|
+
if (typeof value !== "string") invalid();
|
|
39
|
+
if (flag === "--cond" && CONDITION.test(value) && !value.includes("..") && !conditions.includes(value)) conditions.push(value);
|
|
40
|
+
else if (flag === "--since" && since === null && CURSOR.test(value)) since = value;
|
|
41
|
+
else invalid();
|
|
42
|
+
}
|
|
43
|
+
if (conditions.length < 1 || conditions.length > MAX_CONDITIONS) invalid();
|
|
44
|
+
return { conditions, since };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The receipt is bb's last JSON line; only the fields the mod needs leave here.
|
|
48
|
+
export function summarize(exit, stdout) {
|
|
49
|
+
let receipt = null;
|
|
50
|
+
for (const line of String(stdout).trim().split("\n").reverse()) {
|
|
51
|
+
try {
|
|
52
|
+
receipt = JSON.parse(line);
|
|
53
|
+
break;
|
|
54
|
+
} catch {
|
|
55
|
+
/* banner or log line */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const cursor = receipt?.next_cursor == null ? null : String(receipt.next_cursor);
|
|
59
|
+
const id = receipt?.wait_session_id;
|
|
60
|
+
return {
|
|
61
|
+
exit,
|
|
62
|
+
outcome: typeof receipt?.outcome === "string" && /^[a-z_]{1,40}$/.test(receipt.outcome) ? receipt.outcome : "error",
|
|
63
|
+
wait_id: typeof id === "string" && /^[0-9a-z-]{1,64}$/i.test(id) ? id : null,
|
|
64
|
+
next_cursor: cursor && CURSOR.test(cursor) ? cursor : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runRelay(
|
|
69
|
+
argv,
|
|
70
|
+
{ execPath = process.execPath, cliEntry = CLI_ENTRY, execFile = promisify(nodeExecFile), cwd } = {},
|
|
71
|
+
) {
|
|
72
|
+
const { conditions, since } = relayArgs(argv);
|
|
73
|
+
const args = [
|
|
74
|
+
cliEntry,
|
|
75
|
+
"wait",
|
|
76
|
+
...conditions,
|
|
77
|
+
"--timeout",
|
|
78
|
+
String(WAIT_TIMEOUT_SECONDS),
|
|
79
|
+
"--allow-short-timeout",
|
|
80
|
+
SHORT_TIMEOUT_REASON,
|
|
81
|
+
...(since ? ["--since", since] : []),
|
|
82
|
+
];
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await execFile(execPath, args, {
|
|
85
|
+
cwd,
|
|
86
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
87
|
+
timeout: (WAIT_TIMEOUT_SECONDS + 25) * 1000,
|
|
88
|
+
});
|
|
89
|
+
return summarize(0, stdout);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return summarize(typeof error?.code === "number" ? error.code : 7, error?.stdout ?? "");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function main(argv, { out = process.stdout, cwd = process.cwd() } = {}) {
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await runRelay(argv, { cwd });
|
|
99
|
+
} catch {
|
|
100
|
+
res = { exit: 4, outcome: "error", error: "invalid_arguments", wait_id: null, next_cursor: null };
|
|
101
|
+
}
|
|
102
|
+
out.write(JSON.stringify(res) + "\n");
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// BOT-3152: `bb status-band` — the bb-status band's host side, run from the
|
|
2
|
+
// installed CLI (never a repo checkout), and its one-time Claude Code install.
|
|
3
|
+
//
|
|
4
|
+
// bb status-band read --harness-session-id <uuid> [--ticket-id K[,K…]] [--skip-epic]
|
|
5
|
+
// bb status-band relay --cond <condition>… [--since <cursor>]
|
|
6
|
+
// bb status-band install
|
|
7
|
+
//
|
|
8
|
+
// `read` and `relay` print one JSON line for the mod. `install` points the
|
|
9
|
+
// `botbuddy-labs` Claude Code marketplace at the plugin shipped in this package
|
|
10
|
+
// and installs `bb-status` at user scope, so every worktree gets the band.
|
|
11
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
|
|
17
|
+
export const MARKETPLACE = "botbuddy-labs";
|
|
18
|
+
export const PLUGIN = "bb-status";
|
|
19
|
+
const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
export const PACKAGE_ROOT = resolve(HERE, "..");
|
|
22
|
+
export const MARKETPLACE_DIR = join(PACKAGE_ROOT, "claude-plugin");
|
|
23
|
+
|
|
24
|
+
const HELP = `bb status-band <read|relay|install>
|
|
25
|
+
|
|
26
|
+
read --harness-session-id <uuid> [--ticket-id KEY[,KEY…]] [--skip-epic]
|
|
27
|
+
One JSON snapshot of this worktree's BB session for the bb-status band.
|
|
28
|
+
relay --cond <condition>… [--since <cursor>]
|
|
29
|
+
Arm one bb wait on the band's subjects and print how it ended.
|
|
30
|
+
install
|
|
31
|
+
Install the bb-status band into Claude Code once, at user scope, from
|
|
32
|
+
this installed CLI package. bb setup and bb update run it for you.`;
|
|
33
|
+
|
|
34
|
+
export async function cmdStatusBand(args, { log = (l) => console.log(l), out = process.stdout } = {}) {
|
|
35
|
+
const [sub, ...rest] = args;
|
|
36
|
+
if (sub === "read") {
|
|
37
|
+
const { main } = await import("./status-band-read.mjs");
|
|
38
|
+
return main(rest, { out });
|
|
39
|
+
}
|
|
40
|
+
if (sub === "relay") {
|
|
41
|
+
const { main } = await import("./status-band-relay.mjs");
|
|
42
|
+
return main(rest, { out });
|
|
43
|
+
}
|
|
44
|
+
if (sub === "install") {
|
|
45
|
+
const result = await installStatusBand();
|
|
46
|
+
log(statusBandLine(result));
|
|
47
|
+
return result.status === "failed" ? 1 : 0;
|
|
48
|
+
}
|
|
49
|
+
log(HELP);
|
|
50
|
+
return sub === undefined || sub === "--help" || sub === "-h" || sub === "help" ? 0 : 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A plugin served from a repo checkout dies with that checkout (the BOT-3152
|
|
54
|
+
// fragility), so only a package installed under node_modules is offered.
|
|
55
|
+
export function isInstalledPackage(root = PACKAGE_ROOT) {
|
|
56
|
+
return root.split(sep).includes("node_modules");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function claudeJson(run, args) {
|
|
60
|
+
const { stdout } = await run(["plugin", ...args, "--json"]);
|
|
61
|
+
return JSON.parse(stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Point `botbuddy-labs` at this package's plugin folder and install `bb-status`
|
|
66
|
+
* at user scope. Idempotent; never throws. Returns { status, … } where status is
|
|
67
|
+
* installed | updated | unchanged | skipped | failed.
|
|
68
|
+
*/
|
|
69
|
+
export async function installStatusBand({
|
|
70
|
+
marketplaceDir = MARKETPLACE_DIR,
|
|
71
|
+
packageRoot = PACKAGE_ROOT,
|
|
72
|
+
claude = "claude",
|
|
73
|
+
execFileImpl = promisify(nodeExecFile),
|
|
74
|
+
readJson = async (p) => JSON.parse(await readFile(p, "utf8")),
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (!isInstalledPackage(packageRoot)) return { status: "skipped", reason: "not_installed_package" };
|
|
77
|
+
const run = (args) => execFileImpl(claude, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024 });
|
|
78
|
+
try {
|
|
79
|
+
await run(["--version"]);
|
|
80
|
+
} catch {
|
|
81
|
+
return { status: "skipped", reason: "claude_missing" };
|
|
82
|
+
}
|
|
83
|
+
let shipped;
|
|
84
|
+
try {
|
|
85
|
+
shipped = (await readJson(join(marketplaceDir, PLUGIN, ".claude-plugin", "plugin.json"))).version;
|
|
86
|
+
} catch {
|
|
87
|
+
return { status: "failed", reason: "plugin_missing_from_package" };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const markets = await claudeJson(run, ["marketplace", "list"]);
|
|
91
|
+
const current = Array.isArray(markets) ? markets.find((m) => m?.name === MARKETPLACE) : null;
|
|
92
|
+
const repointed = Boolean(current) && resolve(String(current.path ?? "")) !== resolve(marketplaceDir);
|
|
93
|
+
if (repointed) await run(["plugin", "marketplace", "remove", MARKETPLACE]);
|
|
94
|
+
if (!current || repointed) await run(["plugin", "marketplace", "add", marketplaceDir, "--scope", "user"]);
|
|
95
|
+
else await run(["plugin", "marketplace", "update", MARKETPLACE]);
|
|
96
|
+
const plugins = await claudeJson(run, ["list"]);
|
|
97
|
+
const mine = Array.isArray(plugins) ? plugins.find((p) => p?.id === PLUGIN_ID && p?.scope === "user") : null;
|
|
98
|
+
if (mine && mine.version === shipped && mine.enabled !== false && !repointed) {
|
|
99
|
+
return { status: "unchanged", version: shipped };
|
|
100
|
+
}
|
|
101
|
+
if (mine && !repointed) await run(["plugin", "update", PLUGIN_ID, "--scope", "user"]);
|
|
102
|
+
else await run(["plugin", "install", PLUGIN_ID, "--scope", "user"]);
|
|
103
|
+
if (mine && mine.enabled === false) await run(["plugin", "enable", PLUGIN_ID, "--scope", "user"]);
|
|
104
|
+
return { status: mine && !repointed ? "updated" : "installed", version: shipped, ...(repointed ? { replaced: String(current.path ?? "") } : {}) };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const detail = String(error?.stderr || error?.message || error).trim().split("\n")[0].slice(0, 200);
|
|
107
|
+
return { status: "failed", reason: "claude_plugin_command_failed", detail };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function statusBandLine(result) {
|
|
112
|
+
switch (result?.status) {
|
|
113
|
+
case "installed":
|
|
114
|
+
return `bb-status band installed in Claude Code (user scope, v${result.version}); new sessions show it. Run /reload-plugins in open ones.`;
|
|
115
|
+
case "updated":
|
|
116
|
+
return `bb-status band updated in Claude Code to v${result.version}; new sessions show it. Run /reload-plugins in open ones.`;
|
|
117
|
+
case "unchanged":
|
|
118
|
+
return `bb-status band already installed in Claude Code (v${result.version}).`;
|
|
119
|
+
case "skipped":
|
|
120
|
+
return result.reason === "claude_missing"
|
|
121
|
+
? "bb-status band skipped: no claude command on PATH."
|
|
122
|
+
: "bb-status band skipped: this bb runs from a checkout, not an installed package (npm i -g @botbuddy/cli).";
|
|
123
|
+
default:
|
|
124
|
+
return `bb-status band could not be installed (${result?.reason ?? "unknown"}${result?.detail ? `: ${result.detail}` : ""}); run bb status-band install to retry.`;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/update-check.mjs
CHANGED
|
@@ -144,7 +144,7 @@ export function readInstalledVersion({ npmBin = "npm", shellOpt = {}, probeImpl
|
|
|
144
144
|
|
|
145
145
|
// `botbuddy update` waits for npm because the terminal result, rather than the
|
|
146
146
|
// child-process launch, is the only trustworthy confirmation an update worked.
|
|
147
|
-
export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, readInstalledVersion: installedVersion = readInstalledVersion, refreshPools = refreshPoolsFromNewPackage, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
|
|
147
|
+
export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, readInstalledVersion: installedVersion = readInstalledVersion, refreshPools = refreshPoolsFromNewPackage, installBand = installBandFromNewPackage, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
|
|
148
148
|
// BOT-1566 (Codex P1/P2): on Windows npm is the `npm.cmd` shim, and Node
|
|
149
149
|
// cannot spawn a .cmd file shell-free (docs) — run it through a shell on
|
|
150
150
|
// win32 (args are all static constants, so no injection surface) and select
|
|
@@ -208,6 +208,11 @@ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = sp
|
|
|
208
208
|
.catch((error) => {
|
|
209
209
|
try { stderr.write(`botbuddy update: the installed staging pools were not refreshed (${error?.message ?? error}); run bb staging-pool status\n`); } catch { /* ignore */ }
|
|
210
210
|
})
|
|
211
|
+
// BOT-3152: the new package re-points the bb-status band at itself; never an exit code.
|
|
212
|
+
.then(() => installBand({ npmBin, shellOpt, probeImpl, log }))
|
|
213
|
+
.catch((error) => {
|
|
214
|
+
try { stderr.write(`botbuddy update: the bb-status band was not refreshed (${error?.message ?? error}); run bb status-band install\n`); } catch { /* ignore */ }
|
|
215
|
+
})
|
|
211
216
|
.finally(() => resolve(0));
|
|
212
217
|
});
|
|
213
218
|
});
|
|
@@ -299,6 +304,15 @@ export async function refreshInstalledPools({
|
|
|
299
304
|
return results;
|
|
300
305
|
}
|
|
301
306
|
|
|
307
|
+
// BOT-3152: run the NEW package's `status-band install` (this process is the old
|
|
308
|
+
// one), so Claude Code's bb-status band and its marketplace match the update.
|
|
309
|
+
export async function installBandFromNewPackage({ npmBin, shellOpt, probeImpl, log, nodeBin = process.execPath, execFileImpl = promisify(execFile) }) {
|
|
310
|
+
const root = readNpmGlobalRoot({ npmBin, shellOpt, probeImpl });
|
|
311
|
+
const { stdout } = await execFileImpl(nodeBin, [join(root, "@botbuddy", "cli", "bin", "botbuddy.mjs"), "status-band", "install"], { timeout: 180_000, maxBuffer: 1024 * 1024 });
|
|
312
|
+
const line = String(stdout).trim().split("\n").filter(Boolean).pop();
|
|
313
|
+
if (line) log(line);
|
|
314
|
+
}
|
|
315
|
+
|
|
302
316
|
// The default for `cmdUpdate`: locate the newly installed package and refresh with it.
|
|
303
317
|
export async function refreshPoolsFromNewPackage({ installedVersion, npmBin, shellOpt, probeImpl, log, stderr }) {
|
|
304
318
|
const root = readNpmGlobalRoot({ npmBin, shellOpt, probeImpl });
|
package/src/wait-core.mjs
CHANGED
|
@@ -119,6 +119,9 @@ export const SIGNAL_TYPES = Object.freeze([
|
|
|
119
119
|
// BOT-3136: one signal per member-issue change of a Linear project (an epic),
|
|
120
120
|
// subject = the project's lower-case UUID. Tenant-primary like linear_issue.
|
|
121
121
|
"linear_project",
|
|
122
|
+
// BOT-3152: a staging incident of the caller's tenant opening or closing
|
|
123
|
+
// (subject = owner/repo). The bb-status band's staging banner wakes on it.
|
|
124
|
+
"staging_incident",
|
|
122
125
|
]);
|
|
123
126
|
|
|
124
127
|
const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
|
package/src/wait.mjs
CHANGED
|
@@ -255,6 +255,9 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
255
255
|
event:type=linear_project,subject=<Linear project UUID>;
|
|
256
256
|
it wakes when any issue in that project changes, joins
|
|
257
257
|
or leaves it (the band's epic row, BOT-3136).
|
|
258
|
+
Staging state uses event:type=staging_incident;
|
|
259
|
+
it wakes when one of your tenant's staging incidents
|
|
260
|
+
opens or closes (the band's staging banner, BOT-3152).
|
|
258
261
|
|
|
259
262
|
OPTIONS
|
|
260
263
|
Review intervention: exact-head Codex waits have a durable one-shot diagnostic.
|