@paxnax/berliner 0.0.0-stage → 0.2.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 +119 -2
- package/dist/agents/claude.js +61 -0
- package/dist/agents/codex.js +43 -0
- package/dist/agents/index.js +35 -0
- package/dist/agents/opencode.js +27 -0
- package/dist/agents/read.js +37 -0
- package/dist/agents/types.js +1 -0
- package/dist/args.js +45 -0
- package/dist/client.js +65 -0
- package/dist/commands/body.js +144 -0
- package/dist/commands/delete.js +35 -0
- package/dist/commands/hook.js +228 -0
- package/dist/commands/query.js +25 -0
- package/dist/commands/report.js +65 -0
- package/dist/commands/session.js +33 -0
- package/dist/commands/sessions.js +17 -0
- package/dist/commands/setup.js +174 -0
- package/dist/commands/skill.js +5 -0
- package/dist/commands/summary.js +25 -0
- package/dist/commands/update.js +21 -0
- package/dist/config.js +33 -0
- package/dist/git.js +42 -0
- package/dist/index.js +71 -0
- package/dist/output.js +28 -0
- package/dist/prices.js +98 -0
- package/dist/setup/connect.js +42 -0
- package/dist/setup/hooks.js +84 -0
- package/dist/setup/targets.js +41 -0
- package/dist/upload.js +94 -0
- package/opencode/berliner.js +124 -0
- package/package.json +28 -4
- package/skill/SKILL.md +103 -0
package/dist/git.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
// Each fact is best effort: no git, no repository, or a slow disk gives null.
|
|
5
|
+
async function git(cwd, args) {
|
|
6
|
+
try {
|
|
7
|
+
return (await run("git", args, { cwd, timeout: 2000 })).stdout.trim();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// git@github.com:owner/name.git, ssh://git@github.com/owner/name.git, https://[user@]github.com/owner/name[.git]
|
|
14
|
+
const GITHUB = /^(?:git@github\.com:|(?:https|ssh|git):\/\/(?:[^@/]+@)?github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i;
|
|
15
|
+
// Any other SSH remote: [user@]host:owner/name[.git] or ssh://[user@]host[:port]/owner/name[.git]. The host never starts with
|
|
16
|
+
// "-", so ssh can't read it as an option.
|
|
17
|
+
const SSH = /^(?:(?:[^@/:]+@)?(\w[\w.-]*):|ssh:\/\/(?:[^@/]+@)?(\w[\w.-]*)(?::\d+)?\/)([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i;
|
|
18
|
+
// owner/name of a GitHub origin. An SSH host alias (Host github-work, HostName github.com) counts: `ssh -G` only prints the
|
|
19
|
+
// config it resolves, without connecting. ssh.github.com is GitHub's SSH over port 443.
|
|
20
|
+
async function repository(origin, sshConfig) {
|
|
21
|
+
const github = origin?.match(GITHUB)?.[1];
|
|
22
|
+
const ssh = github || !origin ? null : origin.match(SSH);
|
|
23
|
+
if (!ssh)
|
|
24
|
+
return github ?? null;
|
|
25
|
+
const config = await run("ssh", [...(sshConfig ? ["-F", sshConfig] : []), "-G", ssh[1] ?? ssh[2]], { timeout: 2000 }).then((r) => r.stdout, () => "");
|
|
26
|
+
return /^hostname (ssh\.)?github\.com$/im.test(config) ? ssh[3] : null;
|
|
27
|
+
}
|
|
28
|
+
// sshConfig is for tests only: ssh finds ~/.ssh/config through the user database, not $HOME, so a test passes its own file.
|
|
29
|
+
export async function gitFacts(cwd, since, sshConfig) {
|
|
30
|
+
const [origin, branch, email] = await Promise.all([
|
|
31
|
+
git(cwd, ["remote", "get-url", "origin"]),
|
|
32
|
+
git(cwd, ["branch", "--show-current"]),
|
|
33
|
+
git(cwd, ["config", "user.email"]),
|
|
34
|
+
]);
|
|
35
|
+
let commits = null;
|
|
36
|
+
if (since && email) {
|
|
37
|
+
// -F with the brackets matches the author's email exactly, not as a pattern or a part of a longer address.
|
|
38
|
+
const count = await git(cwd, ["rev-list", "--count", "-F", `--author=<${email}>`, `--since=${since}`, "HEAD"]);
|
|
39
|
+
commits = count === null ? null : Number(count);
|
|
40
|
+
}
|
|
41
|
+
return { repository: await repository(origin, sshConfig), branch: branch || null, commits };
|
|
42
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
//usr/bin/true; exec "$(command -v node || command -v bun)" "$0" "$@"
|
|
3
|
+
import { parse } from "./args.js";
|
|
4
|
+
import { CliError } from "./client.js";
|
|
5
|
+
import { del } from "./commands/delete.js";
|
|
6
|
+
import { hook } from "./commands/hook.js";
|
|
7
|
+
import { report } from "./commands/report.js";
|
|
8
|
+
import { session } from "./commands/session.js";
|
|
9
|
+
import { sessions } from "./commands/sessions.js";
|
|
10
|
+
import { setup } from "./commands/setup.js";
|
|
11
|
+
import { skill } from "./commands/skill.js";
|
|
12
|
+
import { summary } from "./commands/summary.js";
|
|
13
|
+
import { update } from "./commands/update.js";
|
|
14
|
+
import { readConfig } from "./config.js";
|
|
15
|
+
import { warn } from "./output.js";
|
|
16
|
+
const usage = `Usage: berliner <command> [flags]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
setup connect this terminal, install the CLI and skill [--yes --no-install --no-browser]
|
|
20
|
+
report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p>
|
|
21
|
+
[--task --task-url --project --started-at --ended-at --commits --coverage]
|
|
22
|
+
[--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--json] [--strict]
|
|
23
|
+
inside Claude Code, Codex or OpenCode the session id, cost, model and repo are filled in,
|
|
24
|
+
and the server picks the plan (BERLINER_PLAN overrides it)
|
|
25
|
+
update <session-id> same flags as report, all optional (no session id inside those tools)
|
|
26
|
+
sessions [--repo --model --project --ci --merge --since <N>d | --from --to] [--limit <n>] [--json]
|
|
27
|
+
session <session-id> [--json]
|
|
28
|
+
summary same filters as sessions [--json]
|
|
29
|
+
delete <session-id> [--evidence <id>] [--yes]
|
|
30
|
+
skill print the bundled SKILL.md
|
|
31
|
+
hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
|
|
32
|
+
|
|
33
|
+
Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
|
|
34
|
+
const args = parse(process.argv.slice(2));
|
|
35
|
+
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
36
|
+
const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
|
|
37
|
+
const handled = new Set(["setup", "report", "update", "sessions", "session", "summary", "delete", "skill", "hook"]);
|
|
38
|
+
const cfg = () => readConfig(process.env);
|
|
39
|
+
try {
|
|
40
|
+
if (args.values.help)
|
|
41
|
+
console.log(usage);
|
|
42
|
+
// Before the usage check: the hook stays silent even when called wrongly, and logs instead.
|
|
43
|
+
else if (args.command === "hook")
|
|
44
|
+
await hook(process.env, args.positionals, args.values);
|
|
45
|
+
else if (args.error)
|
|
46
|
+
throw new CliError(args.error, "usage");
|
|
47
|
+
else if (args.command === "setup")
|
|
48
|
+
await setup(process.env, args.values);
|
|
49
|
+
else if (args.command === "report")
|
|
50
|
+
await report(cfg(), args.values, args.positionals, process.env);
|
|
51
|
+
else if (args.command === "update")
|
|
52
|
+
await update(cfg(), args.values, args.positionals, process.env);
|
|
53
|
+
else if (args.command === "sessions")
|
|
54
|
+
await sessions(cfg(), args.values);
|
|
55
|
+
else if (args.command === "session")
|
|
56
|
+
await session(cfg(), args.values, args.positionals);
|
|
57
|
+
else if (args.command === "summary")
|
|
58
|
+
await summary(cfg(), args.values);
|
|
59
|
+
else if (args.command === "delete")
|
|
60
|
+
await del(cfg(), args.values, args.positionals);
|
|
61
|
+
else if (args.command === "skill")
|
|
62
|
+
skill();
|
|
63
|
+
else
|
|
64
|
+
throw new CliError(args.command ? `unknown command "${args.command}"` : "missing command", "usage");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
warn(err instanceof Error ? err.message : String(err));
|
|
68
|
+
if (args.error === null && !handled.has(args.command ?? ""))
|
|
69
|
+
console.error(usage);
|
|
70
|
+
process.exitCode = softFail ? 0 : 1;
|
|
71
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function print(result, json) {
|
|
2
|
+
console.log(json ? JSON.stringify(result.data, null, 2) : result.text);
|
|
3
|
+
}
|
|
4
|
+
// Always a single line, so agents and CI logs see exactly one warning.
|
|
5
|
+
export function warn(msg) {
|
|
6
|
+
console.error(`berliner: ${msg.replace(/\s*\n\s*/g, " ")}`);
|
|
7
|
+
}
|
|
8
|
+
// Same wording as the web's mergeLabel (app/src/web/format.ts).
|
|
9
|
+
const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
|
|
10
|
+
export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
|
|
11
|
+
// Same wording as the web's subscriptionLabel (app/src/web/format.ts): "Claude Max x20".
|
|
12
|
+
const VENDOR = { anthropic: "Claude", openai: "OpenAI" };
|
|
13
|
+
const PLAN = {
|
|
14
|
+
free: "Free",
|
|
15
|
+
pro: "Pro",
|
|
16
|
+
max_x5: "Max x5",
|
|
17
|
+
max_x20: "Max x20",
|
|
18
|
+
team: "Team",
|
|
19
|
+
enterprise: "Enterprise",
|
|
20
|
+
go: "Go",
|
|
21
|
+
plus: "Plus",
|
|
22
|
+
pro_more: "Pro More",
|
|
23
|
+
pro_max: "Pro Max",
|
|
24
|
+
business: "Business",
|
|
25
|
+
business_premium: "Business Premium",
|
|
26
|
+
api: "API",
|
|
27
|
+
};
|
|
28
|
+
export const subscriptionLabel = (s) => `${VENDOR[s.vendor] ?? s.vendor} ${PLAN[s.plan] ?? s.plan}`;
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cacheDir } from "./config.js";
|
|
4
|
+
const SOURCE = "https://models.dev/api.json";
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
async function fetchSource(env) {
|
|
7
|
+
// BERLINER_PRICES_FILE stands in for models.dev so tests never touch the network. Deliberately undocumented: it's a test seam, not a setting.
|
|
8
|
+
if (env.BERLINER_PRICES_FILE)
|
|
9
|
+
return JSON.parse(await readFile(env.BERLINER_PRICES_FILE, "utf8"));
|
|
10
|
+
// Well inside the hook child's 15-second deadline, so a slow models.dev never costs the report.
|
|
11
|
+
const res = await fetch(SOURCE, { signal: AbortSignal.timeout(4_000) });
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error(`${SOURCE} answered ${res.status}`);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
// Resellers repeat the first-party ids, so a model's price comes from anthropic, then openai, then the first provider listing it.
|
|
17
|
+
function slim(data) {
|
|
18
|
+
if (typeof data !== "object" || data === null)
|
|
19
|
+
throw new Error("unexpected price list");
|
|
20
|
+
const providers = data;
|
|
21
|
+
const order = ["anthropic", "openai", ...Object.keys(providers).filter((p) => p !== "anthropic" && p !== "openai")];
|
|
22
|
+
const prices = {};
|
|
23
|
+
for (const provider of order) {
|
|
24
|
+
for (const [id, model] of Object.entries(providers[provider]?.models ?? {})) {
|
|
25
|
+
const c = model?.cost;
|
|
26
|
+
if (Object.hasOwn(prices, id) || typeof c?.input !== "number" || typeof c.output !== "number")
|
|
27
|
+
continue;
|
|
28
|
+
// shortcut: tiered prices by context size are ignored; upgrade if estimates drift from the tools' own figures.
|
|
29
|
+
const price = { input: c.input, output: c.output };
|
|
30
|
+
if (typeof c.cache_read === "number")
|
|
31
|
+
price.cache_read = c.cache_read;
|
|
32
|
+
if (typeof c.cache_write === "number")
|
|
33
|
+
price.cache_write = c.cache_write;
|
|
34
|
+
prices[id] = price;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return prices;
|
|
38
|
+
}
|
|
39
|
+
async function readCache(file) {
|
|
40
|
+
try {
|
|
41
|
+
const [text, info] = await Promise.all([readFile(file, "utf8"), stat(file)]);
|
|
42
|
+
const prices = JSON.parse(text);
|
|
43
|
+
if (typeof prices !== "object" || prices === null || !Object.keys(prices).length)
|
|
44
|
+
return null;
|
|
45
|
+
return { prices: prices, fresh: Date.now() - info.mtimeMs < DAY_MS };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Prices cached for a day. A failed fetch falls back to the cache at any age; null when there is neither.
|
|
52
|
+
export async function loadPrices(env) {
|
|
53
|
+
const file = join(cacheDir(env), "prices.json");
|
|
54
|
+
const cached = await readCache(file);
|
|
55
|
+
if (cached?.fresh)
|
|
56
|
+
return cached.prices;
|
|
57
|
+
let prices;
|
|
58
|
+
try {
|
|
59
|
+
prices = slim(await fetchSource(env));
|
|
60
|
+
// A list without a single price is a broken answer: never cached, never used.
|
|
61
|
+
if (!Object.keys(prices).length)
|
|
62
|
+
throw new Error("no prices");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Touched, so the calls of the next day use the stale cache instead of waiting on the source again.
|
|
66
|
+
if (cached)
|
|
67
|
+
await utimes(file, new Date(), new Date()).catch(() => { });
|
|
68
|
+
return cached?.prices ?? null;
|
|
69
|
+
}
|
|
70
|
+
// The cache only saves a download; failing to write it is not an error. Renamed into place, so a reader never sees half of it.
|
|
71
|
+
const temp = `${file}.${process.pid}`;
|
|
72
|
+
await mkdir(cacheDir(env), { recursive: true })
|
|
73
|
+
.then(() => writeFile(temp, JSON.stringify(prices)))
|
|
74
|
+
.then(() => rename(temp, file))
|
|
75
|
+
.catch(() => rm(temp, { force: true }).catch(() => { }));
|
|
76
|
+
return prices;
|
|
77
|
+
}
|
|
78
|
+
// A missing cache price is charged at the input price. models.dev's cache_write is the 5-minute rate; Anthropic bills 1-hour
|
|
79
|
+
// cache writes at twice the input price.
|
|
80
|
+
export function estimateCost(tokensByModel, prices) {
|
|
81
|
+
let costUsd = 0;
|
|
82
|
+
const unpriced = [];
|
|
83
|
+
for (const [model, t] of Object.entries(tokensByModel)) {
|
|
84
|
+
const p = Object.hasOwn(prices, model) ? prices[model] : undefined;
|
|
85
|
+
if (!p) {
|
|
86
|
+
unpriced.push(model);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
costUsd +=
|
|
90
|
+
(t.input * p.input +
|
|
91
|
+
t.output * p.output +
|
|
92
|
+
t.cacheRead * (p.cache_read ?? p.input) +
|
|
93
|
+
t.cacheWrite * (p.cache_write ?? p.input) +
|
|
94
|
+
t.cacheWrite1h * 2 * p.input) /
|
|
95
|
+
1e6;
|
|
96
|
+
}
|
|
97
|
+
return { costUsd, unpriced };
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { CliError, request } from "../client.js";
|
|
5
|
+
// The name the approval page and Settings show for this machine.
|
|
6
|
+
export const machineName = () => hostname()
|
|
7
|
+
.replace(/\.local$/, "")
|
|
8
|
+
.slice(0, 64);
|
|
9
|
+
// The token is made here, like Settings' tokens; the server only ever sees its hash and prefix.
|
|
10
|
+
export async function startConnect(cfg, clientName) {
|
|
11
|
+
const token = `brl_${randomBytes(32).toString("base64url")}`;
|
|
12
|
+
const body = { client_name: clientName, token_hash: createHash("sha256").update(token).digest("hex"), token_prefix: token.slice(0, 11) };
|
|
13
|
+
const { json } = await request(cfg, "POST", "/v1/connect", body, { auth: false });
|
|
14
|
+
return { ...json, token };
|
|
15
|
+
}
|
|
16
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
export async function waitForApproval(cfg, id, interval, expiresIn) {
|
|
18
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
19
|
+
for (;;) {
|
|
20
|
+
await sleep(interval * 1000);
|
|
21
|
+
try {
|
|
22
|
+
const { json } = await request(cfg, "GET", `/v1/connect/${id}`, undefined, { auth: false, deadlineMs: 15_000 });
|
|
23
|
+
if (json.status !== "pending")
|
|
24
|
+
return json.status;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
// A network blip, a rate limit or a server error is no answer: the request stays pending until it expires. A 404 or
|
|
28
|
+
// another refusal ends the wait.
|
|
29
|
+
const unanswered = err instanceof CliError && (err.kind === "network" || err.status === 429 || (err.status ?? 0) >= 500);
|
|
30
|
+
if (!unanswered)
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (Date.now() >= deadline)
|
|
34
|
+
return "expired";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Best effort: when no browser opens, the printed URL is enough.
|
|
38
|
+
export function openBrowser(url) {
|
|
39
|
+
const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
40
|
+
child.on("error", () => { }); // a missing opener is reported here, not thrown
|
|
41
|
+
child.unref();
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
const entries = (tool, asyncStop) => {
|
|
4
|
+
const hooks = (extra = {}) => [{ type: "command", command: `berliner hook ${tool}`, ...extra }];
|
|
5
|
+
return {
|
|
6
|
+
// Claude Code runs Stop after every reply; async keeps the agent from waiting on it.
|
|
7
|
+
Stop: { hooks: hooks(asyncStop ? { async: true } : {}) },
|
|
8
|
+
PreCompact: { hooks: hooks() },
|
|
9
|
+
SessionEnd: { hooks: hooks() },
|
|
10
|
+
// Not async: what it prints is the reminder, which the tool adds to the agent's context.
|
|
11
|
+
SessionStart: { matcher: "compact|clear", hooks: hooks() },
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
|
+
const ours = (entry) => isObject(entry) &&
|
|
16
|
+
Array.isArray(entry.hooks) &&
|
|
17
|
+
entry.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.startsWith("berliner hook "));
|
|
18
|
+
// The file with our entries added to the events that lack one, or null when nothing is missing. "unreadable" when it
|
|
19
|
+
// isn't a JSON object whose hooks we can add to: then it is the user's to fix, and is never rewritten.
|
|
20
|
+
function merged(file, add) {
|
|
21
|
+
let text = null;
|
|
22
|
+
let json = {};
|
|
23
|
+
try {
|
|
24
|
+
text = readFileSync(file, "utf8");
|
|
25
|
+
json = JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (text !== null || err.code !== "ENOENT")
|
|
29
|
+
return "unreadable";
|
|
30
|
+
}
|
|
31
|
+
if (!isObject(json))
|
|
32
|
+
return "unreadable";
|
|
33
|
+
const hooks = json.hooks ?? {};
|
|
34
|
+
if (!isObject(hooks))
|
|
35
|
+
return "unreadable";
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const [event, entry] of Object.entries(add)) {
|
|
38
|
+
const list = hooks[event] ?? [];
|
|
39
|
+
if (!Array.isArray(list))
|
|
40
|
+
return "unreadable";
|
|
41
|
+
if (list.some(ours))
|
|
42
|
+
continue;
|
|
43
|
+
hooks[event] = [...list, entry];
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
if (!changed)
|
|
47
|
+
return null;
|
|
48
|
+
const indent = text?.match(/^[ \t]+(?=\S)/m)?.[0] ?? " ";
|
|
49
|
+
return `${JSON.stringify({ ...json, hooks }, null, indent)}\n`;
|
|
50
|
+
}
|
|
51
|
+
function install(file, add) {
|
|
52
|
+
const text = merged(file, add);
|
|
53
|
+
if (text === "unreadable")
|
|
54
|
+
return text;
|
|
55
|
+
if (text === null)
|
|
56
|
+
return "present";
|
|
57
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
58
|
+
// In place, so a settings file that is a symlink stays one.
|
|
59
|
+
writeFileSync(file, text);
|
|
60
|
+
return "added";
|
|
61
|
+
}
|
|
62
|
+
const describe = (text) => (text === "unreadable" ? "can't be read, left unchanged" : text === null ? "already installed" : "adds a hook");
|
|
63
|
+
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code", true));
|
|
64
|
+
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code", true)));
|
|
65
|
+
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex", false));
|
|
66
|
+
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex", false)));
|
|
67
|
+
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
68
|
+
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
69
|
+
const current = (pluginPath) => {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(pluginPath, "utf8") === plugin();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
export function installOpenCodePlugin(pluginPath) {
|
|
78
|
+
if (current(pluginPath))
|
|
79
|
+
return "present";
|
|
80
|
+
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
81
|
+
writeFileSync(pluginPath, plugin());
|
|
82
|
+
return "added";
|
|
83
|
+
}
|
|
84
|
+
export const describeOpenCodePlugin = (pluginPath) => (current(pluginPath) ? "already installed" : "adds a hook");
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const claudeDir = (env) => env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
5
|
+
export const codexDir = (env) => env.CODEX_HOME || join(homedir(), ".codex");
|
|
6
|
+
export const opencodeDir = (env) => join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
7
|
+
// The spec's table (docs/superpowers/specs/2026-10-20-onboarding-design.md, "Where each tool looks for skills").
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{ id: "claude-code", name: "Claude Code", bin: "claude", dir: claudeDir },
|
|
10
|
+
{ id: "codex", name: "Codex", bin: "codex", dir: codexDir },
|
|
11
|
+
{ id: "opencode", name: "OpenCode", bin: "opencode", dir: opencodeDir },
|
|
12
|
+
];
|
|
13
|
+
function isExecutable(file) {
|
|
14
|
+
try {
|
|
15
|
+
accessSync(file, constants.X_OK);
|
|
16
|
+
return statSync(file).isFile();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const onPath = (bin, env) => (env.PATH ?? "").split(":").some((dir) => dir !== "" && isExecutable(join(dir, bin)));
|
|
23
|
+
export function detectTools(env) {
|
|
24
|
+
return TOOLS.map((t) => ({ id: t.id, name: t.name, found: onPath(t.bin, env) || existsSync(t.dir(env)) }));
|
|
25
|
+
}
|
|
26
|
+
const skillFile = (dir) => join(dir, "skills", "berliner", "SKILL.md");
|
|
27
|
+
// One target per tool found. OpenCode reads ~/.claude/skills and ~/.agents/skills too, so it gets its own copy only when neither is written.
|
|
28
|
+
export function skillTargets(tools, env) {
|
|
29
|
+
const found = (id) => tools.some((t) => t.id === id && t.found);
|
|
30
|
+
const targets = [];
|
|
31
|
+
if (found("claude-code"))
|
|
32
|
+
targets.push({ tool: "claude-code", path: skillFile(claudeDir(env)) });
|
|
33
|
+
if (found("codex"))
|
|
34
|
+
targets.push({ tool: "codex", path: skillFile(join(homedir(), ".agents")) });
|
|
35
|
+
if (found("opencode")) {
|
|
36
|
+
const read = [".claude", ".agents"].map((d) => skillFile(join(homedir(), d)));
|
|
37
|
+
const covering = targets.find((t) => read.includes(t.path));
|
|
38
|
+
targets.push({ tool: "opencode", path: covering?.path ?? skillFile(opencodeDir(env)) });
|
|
39
|
+
}
|
|
40
|
+
return targets.map((t) => ({ ...t, exists: existsSync(t.path) }));
|
|
41
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CliError, request } from "./client.js";
|
|
6
|
+
const TYPES = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".gif": "image/gif",
|
|
11
|
+
".webp": "image/webp",
|
|
12
|
+
".mp4": "video/mp4",
|
|
13
|
+
".webm": "video/webm",
|
|
14
|
+
".mov": "video/quicktime",
|
|
15
|
+
};
|
|
16
|
+
// Evidence uploads are exempt from the 15 s report deadline (ruling R25).
|
|
17
|
+
const LONG = { timeoutMs: 120_000, deadlineMs: 120_000 };
|
|
18
|
+
export async function uploadEvidence(cfg, sessionId, files, budget = LONG) {
|
|
19
|
+
const result = { uploaded: 0, existing: 0, skipped: [] };
|
|
20
|
+
let unreachable = null;
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (unreachable) {
|
|
23
|
+
result.skipped.push({ path: file.path, reason: unreachable });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (await uploadOne(cfg, sessionId, file, budget))
|
|
28
|
+
result.uploaded++;
|
|
29
|
+
else
|
|
30
|
+
result.existing++;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
// One bad file never fails the report.
|
|
34
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
35
|
+
result.skipped.push({ path: file.path, reason });
|
|
36
|
+
// The next file would wait out the same budget against the same server (anti-goal 1).
|
|
37
|
+
if (err instanceof CliError && err.kind === "network")
|
|
38
|
+
unreachable = reason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
// Resolves true when uploaded, false when the server already had the content.
|
|
44
|
+
async function uploadOne(cfg, sessionId, file, budget) {
|
|
45
|
+
const contentType = TYPES[path.extname(file.path).toLowerCase()];
|
|
46
|
+
if (!contentType)
|
|
47
|
+
throw new Error("unsupported file type");
|
|
48
|
+
const size = await stat(file.path).then((s) => (s.isFile() ? s.size : 0), () => -1);
|
|
49
|
+
if (size < 0)
|
|
50
|
+
throw new Error("file not found");
|
|
51
|
+
if (size === 0)
|
|
52
|
+
throw new Error("file is empty");
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
for await (const chunk of createReadStream(file.path))
|
|
55
|
+
hash.update(chunk);
|
|
56
|
+
const { json } = await request(cfg, "POST", `/v1/sessions/${encodeURIComponent(sessionId)}/evidence`, {
|
|
57
|
+
filename: path.basename(file.path),
|
|
58
|
+
content_type: contentType,
|
|
59
|
+
size_bytes: size,
|
|
60
|
+
sha256: hash.digest("hex"),
|
|
61
|
+
caption: file.caption,
|
|
62
|
+
}, budget);
|
|
63
|
+
if (json.status === "exists")
|
|
64
|
+
return false;
|
|
65
|
+
const base = `/v1/evidence/${json.evidence.id}`;
|
|
66
|
+
const handle = await open(file.path);
|
|
67
|
+
try {
|
|
68
|
+
const slice = async (start, length) => {
|
|
69
|
+
const buf = Buffer.alloc(length);
|
|
70
|
+
const { bytesRead } = await handle.read(buf, 0, length, start);
|
|
71
|
+
if (bytesRead !== length)
|
|
72
|
+
throw new Error("file changed while uploading");
|
|
73
|
+
return buf;
|
|
74
|
+
};
|
|
75
|
+
if (json.upload.mode === "single") {
|
|
76
|
+
await request(cfg, "PUT", `${base}/content`, await slice(0, size), budget);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const parts = [];
|
|
80
|
+
const partSize = json.upload.part_size;
|
|
81
|
+
if (!Number.isInteger(partSize) || partSize < 1)
|
|
82
|
+
throw new Error("the server sent an invalid part size"); // 0 would loop forever
|
|
83
|
+
for (let start = 0, n = 1; start < size; start += partSize, n++) {
|
|
84
|
+
const res = await request(cfg, "PUT", `${base}/parts/${n}`, await slice(start, Math.min(partSize, size - start)), budget);
|
|
85
|
+
parts.push({ part_number: n, etag: res.json.etag });
|
|
86
|
+
}
|
|
87
|
+
await request(cfg, "POST", `${base}/complete`, { parts }, budget);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Berliner's OpenCode plugin, installed by `berliner setup` to ${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/berliner.js.
|
|
2
|
+
// It reports each top-level session to Berliner through `berliner hook opencode`, and must never throw into OpenCode or make it wait.
|
|
3
|
+
//
|
|
4
|
+
// Checked against OpenCode 1.18.30 (https://opencode.ai/docs/plugins/ and @opencode-ai/plugin 1.18.30's Hooks type):
|
|
5
|
+
// - a plugin is an exported async function of { client, directory, ... } that returns its hooks;
|
|
6
|
+
// - `event` receives every bus event; session.idle carries properties.sessionID;
|
|
7
|
+
// - `shell.env` runs before the agent's shell tool (with sessionID) and before user terminals (without one);
|
|
8
|
+
// - `experimental.session.compacting` runs before every compaction, manual (/compact) and automatic alike, and adds
|
|
9
|
+
// output.context to the summary prompt;
|
|
10
|
+
// - client.session.get({ path: { id } }) returns { data } with the server's Session, which holds cost, tokens and model,
|
|
11
|
+
// so the messages are not needed;
|
|
12
|
+
// - subagents run in child sessions (parentID set), each with its own cost and tokens, and
|
|
13
|
+
// client.session.children({ path: { id } }) returns { data } with a session's direct children.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const REMINDER = "This session reports to Berliner: run `berliner report` with the task and PRs before your final message.";
|
|
20
|
+
// The id names a file, so only the characters OpenCode's own ids use.
|
|
21
|
+
const SAFE_ID = /^[\w-]+$/;
|
|
22
|
+
// Bounds a walk up or down the session tree, which subagents keep shallow.
|
|
23
|
+
const MAX_DEPTH = 10;
|
|
24
|
+
|
|
25
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
26
|
+
const get = async (client, id) => (await client.session.get({ path: { id } }))?.data;
|
|
27
|
+
|
|
28
|
+
// One agent session is one Berliner session: a subagent's child session belongs to the top-level session above it.
|
|
29
|
+
async function topLevelId(client, id) {
|
|
30
|
+
let session = await get(client, id);
|
|
31
|
+
for (let i = 0; session?.parentID && i < MAX_DEPTH; i++) session = await get(client, session.parentID);
|
|
32
|
+
return session && !session.parentID ? session.id : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The session with its subagents' cost and tokens added in, in the shape the CLI reads. OpenCode 1.18.30 keeps them apart:
|
|
36
|
+
// a session's cost and tokens grow only from its own step-finish parts (packages/core/src/session/projector.ts:310-326,
|
|
37
|
+
// applyUsage on the part's own sessionID, lines 89-109), and a live run with one task-tool subagent gave the parent 0.215
|
|
38
|
+
// and the child 1.05.
|
|
39
|
+
async function withChildren(client, session, depth = 0) {
|
|
40
|
+
const children = depth < MAX_DEPTH ? (await client.session.children({ path: { id: session.id } }))?.data : null;
|
|
41
|
+
if (!Array.isArray(children) || children.length === 0) return session;
|
|
42
|
+
const t = session.tokens;
|
|
43
|
+
let cost = num(session.cost);
|
|
44
|
+
const tokens = {
|
|
45
|
+
input: num(t?.input),
|
|
46
|
+
output: num(t?.output),
|
|
47
|
+
reasoning: num(t?.reasoning),
|
|
48
|
+
cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
|
|
49
|
+
};
|
|
50
|
+
for (const c of await Promise.all(children.map((child) => withChildren(client, child, depth + 1)))) {
|
|
51
|
+
cost += num(c.cost);
|
|
52
|
+
tokens.input += num(c.tokens?.input);
|
|
53
|
+
tokens.output += num(c.tokens?.output);
|
|
54
|
+
tokens.reasoning += num(c.tokens?.reasoning);
|
|
55
|
+
tokens.cache.read += num(c.tokens?.cache?.read);
|
|
56
|
+
tokens.cache.write += num(c.tokens?.cache?.write);
|
|
57
|
+
}
|
|
58
|
+
return { ...session, cost, tokens };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Saves a top-level session with its subagents' totals where `berliner report` run by the agent finds them: its shell can't
|
|
62
|
+
// reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count.
|
|
63
|
+
async function save(client, sessionID) {
|
|
64
|
+
if (typeof sessionID !== "string" || !SAFE_ID.test(sessionID)) return null;
|
|
65
|
+
const found = await get(client, sessionID);
|
|
66
|
+
if (found?.id !== sessionID || found.parentID) return null;
|
|
67
|
+
// OpenCode moves time.updated only when a prompt arrives, so the session's last activity is now.
|
|
68
|
+
const session = { ...(await withChildren(client, found)), time: { ...found.time, updated: Date.now() } };
|
|
69
|
+
const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner", "opencode");
|
|
70
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
const file = join(dir, `${sessionID}.json`);
|
|
72
|
+
// Renamed into place, so a reader never sees half of it.
|
|
73
|
+
writeFileSync(`${file}.${process.pid}`, JSON.stringify(session));
|
|
74
|
+
renameSync(`${file}.${process.pid}`, file);
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function report(client, event, sessionID) {
|
|
79
|
+
try {
|
|
80
|
+
const session = await save(client, sessionID);
|
|
81
|
+
if (!session) return;
|
|
82
|
+
// Detached and unreferenced: OpenCode never waits on it.
|
|
83
|
+
const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
|
84
|
+
child.on("error", () => {});
|
|
85
|
+
child.stdin.on("error", () => {});
|
|
86
|
+
child.stdin.end(JSON.stringify({ event, session }));
|
|
87
|
+
child.unref();
|
|
88
|
+
} catch {
|
|
89
|
+
// A report that can't be sent is skipped; the next idle sends the latest totals.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const BerlinerPlugin = async ({ client }) => ({
|
|
94
|
+
"shell.env": async (input, output) => {
|
|
95
|
+
try {
|
|
96
|
+
if (!input?.sessionID) return;
|
|
97
|
+
output.env.OPENCODE_SESSION_ID = input.sessionID;
|
|
98
|
+
// A subagent's shell gets the top-level session, so a `berliner report` the agent runs there doesn't split it off.
|
|
99
|
+
const top = await topLevelId(client, input.sessionID);
|
|
100
|
+
if (!top) return;
|
|
101
|
+
output.env.OPENCODE_SESSION_ID = top;
|
|
102
|
+
// So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
|
|
103
|
+
await save(client, top);
|
|
104
|
+
} catch {
|
|
105
|
+
// Never fail the agent's shell.
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
event: async (input) => {
|
|
109
|
+
try {
|
|
110
|
+
if (input?.event?.type === "session.idle") void report(client, "idle", input.event.properties?.sessionID);
|
|
111
|
+
} catch {
|
|
112
|
+
// Never fail OpenCode's event bus.
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"experimental.session.compacting": async (input, output) => {
|
|
116
|
+
try {
|
|
117
|
+
// The summary carries it, so the agent still reports the task and PRs after compaction.
|
|
118
|
+
output.context.push(REMINDER);
|
|
119
|
+
} catch {
|
|
120
|
+
// Never fail the compaction.
|
|
121
|
+
}
|
|
122
|
+
void report(client, "compacting", input?.sessionID);
|
|
123
|
+
},
|
|
124
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paxnax/berliner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Report coding-agent sessions to Berliner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"berliner": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"opencode",
|
|
18
|
+
"skill"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p .",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "24.19.2",
|
|
27
|
+
"typescript": "7.0.2",
|
|
28
|
+
"vitest": "5.0.5"
|
|
29
|
+
}
|
|
30
|
+
}
|