@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.
@@ -0,0 +1,228 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
5
+ import { findRecord, readClaudeCode, readCodex, readOpenCode, SAFE_ID } from "../agents/index.js";
6
+ import { CliError, request } from "../client.js";
7
+ import { readConfig, stateDir } from "../config.js";
8
+ import { gitFacts } from "../git.js";
9
+ import { estimateCost, loadPrices } from "../prices.js";
10
+ const REMINDER = "This session reports to Berliner: run `berliner report` with the task and PRs before your final message.";
11
+ const TOOLS = ["claude-code", "codex", "opencode"];
12
+ const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
13
+ const OPENCODE_EVENTS = ["idle", "compacting"];
14
+ const MAX_PAYLOAD = 1024 * 1024;
15
+ const LOCK_MS = 60_000;
16
+ const DEADLINE_MS = 15_000;
17
+ export function inTool(env) {
18
+ if (env.CLAUDE_CODE_SESSION_ID)
19
+ return { tool: "claude-code", sessionId: env.CLAUDE_CODE_SESSION_ID };
20
+ if (env.CODEX_THREAD_ID)
21
+ return { tool: "codex", sessionId: env.CODEX_THREAD_ID };
22
+ if (env.OPENCODE_SESSION_ID)
23
+ return { tool: "opencode", sessionId: env.OPENCODE_SESSION_ID };
24
+ return null;
25
+ }
26
+ // The session's facts from the tool's own record, with a cost estimated wherever the tool stores none; the cost stays null when
27
+ // no prices load. Claude Code and Codex name the record in the hook payload, and OpenCode's plugin sends the session itself;
28
+ // otherwise it is looked up. Null without a record.
29
+ export async function sessionFacts(tool, sessionId, env, payload = {}) {
30
+ let facts;
31
+ if (tool === "opencode" && payload.session)
32
+ facts = readOpenCode(payload.session);
33
+ else {
34
+ const named = typeof payload.transcript_path === "string" && payload.transcript_path ? payload.transcript_path : null;
35
+ const file = named ?? (await findRecord(tool, sessionId, env));
36
+ if (!file)
37
+ return null;
38
+ if (tool === "claude-code")
39
+ facts = await readClaudeCode(file);
40
+ else if (tool === "codex")
41
+ facts = await readCodex(file);
42
+ else
43
+ facts = readOpenCode(JSON.parse(await readFile(file, "utf8")));
44
+ }
45
+ // Codex's payload names the model the session runs on now.
46
+ if (tool === "codex" && typeof payload.model === "string" && payload.model)
47
+ facts = { ...facts, model: payload.model };
48
+ if (facts.costUsd !== null)
49
+ return facts;
50
+ const prices = await loadPrices(env);
51
+ return prices ? { ...facts, costUsd: estimateCost(facts.tokensByModel, prices).costUsd } : facts;
52
+ }
53
+ // The first prompt's first line cut to 80 characters, else the branch. The repository is the last resort: a new session needs a title.
54
+ export function derivedTitle(firstPrompt, git) {
55
+ const line = firstPrompt?.split("\n")[0].trim();
56
+ return (line && Array.from(line).slice(0, 80).join("")) || git.branch || git.repository;
57
+ }
58
+ // `berliner hook <tool>` runs inside the agent tool: it never prints (bar the reminder), never fails and never makes the agent
59
+ // wait. The work happens in a detached child (`--run`), and every error goes to hook.log.
60
+ export async function hook(env, positionals, values) {
61
+ const tool = positionals[0];
62
+ try {
63
+ if (!TOOLS.includes(tool))
64
+ throw new Error(`unknown tool "${tool}"`);
65
+ if (values.run)
66
+ await child(tool, values.run, env);
67
+ else
68
+ await start(tool, env);
69
+ }
70
+ catch (err) {
71
+ log(env, `${tool}: ${err.message}`);
72
+ }
73
+ }
74
+ async function start(tool, env) {
75
+ const text = await readStdin();
76
+ const p = JSON.parse(text);
77
+ let id;
78
+ if (tool === "opencode") {
79
+ if (!OPENCODE_EVENTS.includes(p.event))
80
+ return;
81
+ id = p.session?.id;
82
+ }
83
+ else {
84
+ // A compaction or /clear may have lost the task and PRs the agent hasn't reported yet.
85
+ if (p.hook_event_name === "SessionStart" && (p.source === "compact" || p.source === "clear"))
86
+ return console.log(REMINDER);
87
+ if (!REPORT_EVENTS.includes(p.hook_event_name))
88
+ return;
89
+ id = p.session_id;
90
+ }
91
+ // The id names the payload file.
92
+ if (typeof id !== "string" || !SAFE_ID.test(id))
93
+ throw new Error("the payload has no usable session id");
94
+ const dir = join(stateDir(env), "hook");
95
+ // Payloads name the user's projects and sessions: only the user may read them.
96
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+ const file = join(dir, `${id}.json`);
98
+ // Renamed into place, so a running child never reads half of this payload.
99
+ // shortcut: payload files are kept, one small file per session, as a child can't tell whether a newer one is on its way.
100
+ writeFileSync(`${file}.${process.pid}`, text);
101
+ renameSync(`${file}.${process.pid}`, file);
102
+ // No stdio and its own process group: the tool waits on this process only, and the child outlives it.
103
+ spawn(process.execPath, [process.argv[1], "hook", tool, "--run", file], { detached: true, stdio: "ignore" })
104
+ .on("error", (err) => log(env, `${tool} ${id}: ${err.message}`))
105
+ .unref();
106
+ }
107
+ async function readStdin() {
108
+ const chunks = [];
109
+ let size = 0;
110
+ for await (const chunk of process.stdin) {
111
+ size += chunk.length;
112
+ if (size > MAX_PAYLOAD)
113
+ throw new Error("the payload is over 1 MB");
114
+ chunks.push(chunk);
115
+ }
116
+ return Buffer.concat(chunks).toString("utf8");
117
+ }
118
+ // One child per session at a time. Each child leaves a note, then tries the lock: one that can't get it exits, and the holder
119
+ // sends the latest payload again for as long as notes keep coming, so the latest totals win.
120
+ async function child(tool, file, env) {
121
+ const base = file.replace(/\.json$/, "");
122
+ const lock = `${base}.lock`;
123
+ const again = `${base}.again`;
124
+ const where = `${tool} ${basename(base)}`;
125
+ // Left before trying the lock, so a holder that checks after this child gave up still finds it.
126
+ writeFileSync(again, "");
127
+ if (!takeLock(lock))
128
+ return;
129
+ // The CLI's usual 15-second deadline, for the whole child, a slow price download included.
130
+ // shortcut: a note still waiting when the deadline hits is sent by the next event's child.
131
+ const timer = setTimeout(() => {
132
+ log(env, `${where}: gave up after 15 s`);
133
+ rmSync(lock, { force: true });
134
+ process.exit(0);
135
+ }, DEADLINE_MS).unref();
136
+ try {
137
+ do {
138
+ while (existsSync(again)) {
139
+ rmSync(again, { force: true });
140
+ await send(tool, file, env).catch((err) => log(env, `${where}: ${err.message}`));
141
+ }
142
+ rmSync(lock, { force: true });
143
+ // A note left after the last check but before the unlock: take the lock back for it, unless a newer child already has.
144
+ } while (existsSync(again) && takeLock(lock));
145
+ }
146
+ finally {
147
+ clearTimeout(timer);
148
+ }
149
+ }
150
+ // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is removed and
151
+ // created again with wx, so of two children that find it stale only one gets it, and the other exits as for a held lock.
152
+ function takeLock(lock) {
153
+ if (createLock(lock))
154
+ return true;
155
+ if (Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? 0) < LOCK_MS)
156
+ return false;
157
+ // shortcut: a child that found the lock stale just before another one replaced it removes the new lock; this needs a child to have died.
158
+ rmSync(lock, { force: true });
159
+ return createLock(lock);
160
+ }
161
+ function createLock(lock) {
162
+ try {
163
+ writeFileSync(lock, String(process.pid), { flag: "wx" });
164
+ return true;
165
+ }
166
+ catch (err) {
167
+ if (err.code === "EEXIST")
168
+ return false;
169
+ throw err;
170
+ }
171
+ }
172
+ // Only the fields the machine knows. The task, PRs, coverage, evidence, project, an existing title and the plan are the agent's.
173
+ async function send(tool, file, env) {
174
+ const payload = JSON.parse(await readFile(file, "utf8"));
175
+ const sessionId = String(tool === "opencode" ? payload.session?.id : payload.session_id);
176
+ const cwd = tool === "opencode" ? payload.session?.directory : payload.cwd;
177
+ if (typeof cwd !== "string")
178
+ throw new Error("the payload has no cwd");
179
+ const facts = await sessionFacts(tool, sessionId, env, payload);
180
+ if (!facts)
181
+ throw new Error("no record of this session");
182
+ const git = await gitFacts(cwd, facts.startedAt);
183
+ // The repository is required, and it is how Berliner groups work.
184
+ if (!git.repository)
185
+ throw new Error(`no GitHub repository at ${cwd}, not reported`);
186
+ const body = {
187
+ // Unknown without prices: left out, so a cost already saved stays.
188
+ cost_usd: facts.costUsd ?? undefined,
189
+ model: facts.model ?? undefined,
190
+ commits: git.commits ?? undefined,
191
+ started_at: facts.startedAt ?? undefined,
192
+ ended_at: facts.endedAt ?? undefined,
193
+ repository: git.repository,
194
+ };
195
+ const cfg = readConfig(env);
196
+ const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
197
+ try {
198
+ await request(cfg, "PATCH", path, body);
199
+ }
200
+ catch (err) {
201
+ if (!(err instanceof CliError && err.status === 404))
202
+ throw err;
203
+ // shortcut: an agent report landing between the 404 and this PUT gets the derived title; a create-only call would close that.
204
+ // The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
205
+ // session needs a cost: 0 until prices load.
206
+ const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan });
207
+ try {
208
+ await create(cfg.plan ?? undefined);
209
+ }
210
+ catch (err) {
211
+ // A BERLINER_PLAN the server rejects must not lose the session: the server picks the plan instead.
212
+ if (!(cfg.plan && err instanceof CliError && err.status === 422 && err.details?.fieldErrors?.plan))
213
+ throw err;
214
+ await create(undefined);
215
+ log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
216
+ }
217
+ }
218
+ }
219
+ // shortcut: hook.log is never rotated (one short line per skipped or failed report); rotate it if it grows large in practice.
220
+ function log(env, line) {
221
+ try {
222
+ mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
223
+ appendFileSync(join(stateDir(env), "hook.log"), `${new Date().toISOString()} ${line.replace(/\s*\n\s*/g, " ")}\n`);
224
+ }
225
+ catch {
226
+ // Nowhere left to record it, and the hook must stay silent.
227
+ }
228
+ }
@@ -0,0 +1,25 @@
1
+ import { CliError } from "../client.js";
2
+ // Filters shared by `sessions` and `summary`, mapped to the API's query string.
3
+ export function filterQuery(values, extra = {}) {
4
+ const q = new URLSearchParams();
5
+ const set = (k, v) => v !== undefined && q.set(k, v);
6
+ set("repository", values.repo);
7
+ set("model", values.model);
8
+ set("project", values.project);
9
+ set("ci", values.ci);
10
+ set("merge", values.merge);
11
+ if (values.since !== undefined) {
12
+ const m = /^(\d+)d$/.exec(values.since);
13
+ if (!m)
14
+ throw new CliError(`--since must look like 30d, got "${values.since}"`, "usage");
15
+ const to = new Date();
16
+ q.set("to", to.toISOString());
17
+ q.set("from", new Date(to.getTime() - Number(m[1]) * 864e5).toISOString());
18
+ }
19
+ set("from", values.from);
20
+ set("to", values.to);
21
+ q.set("tz", Intl.DateTimeFormat().resolvedOptions().timeZone);
22
+ for (const [k, v] of Object.entries(extra))
23
+ set(k, v);
24
+ return `?${q}`;
25
+ }
@@ -0,0 +1,65 @@
1
+ import { CliError } from "../client.js";
2
+ import { print, warn } from "../output.js";
3
+ import { uploadEvidence } from "../upload.js";
4
+ import { fromTool, saveSession, sessionBody } from "./body.js";
5
+ import { inTool } from "./hook.js";
6
+ const required = ["session-id", "title", "repo", "cost", "model", "plan"];
7
+ export async function report(cfg, values, positionals, env) {
8
+ // Usually an unquoted value with spaces, e.g. --title Fix the bug.
9
+ if (positionals.length)
10
+ throw new CliError(`unexpected argument "${positionals[0]}"; quote values that contain spaces`, "usage");
11
+ const tool = inTool(env);
12
+ const titled = values.title !== undefined;
13
+ if (tool)
14
+ values = await fromTool(tool, values, env, "report");
15
+ // BERLINER_PLAN stands in for --plan. Inside a tool, a report without a plan leaves it to the server, and one without a
16
+ // cost has no prices to estimate it.
17
+ const missing = required.filter((flag) => values[flag] === undefined && !(flag === "plan" && cfg.plan) && !(tool && (flag === "plan" || flag === "cost")));
18
+ if (missing.length)
19
+ throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
20
+ const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
21
+ const id = values["session-id"];
22
+ const path = `/v1/sessions/${encodeURIComponent(id)}`;
23
+ // Inside a tool a report without --title is usually a follow-up: the derived title is only for a new session. A report
24
+ // without a cost must not replace the saved one either.
25
+ const { json: saved, dropped } = await (tool && (!titled || body.cost_usd === undefined)
26
+ ? patchFirst(cfg, path, body, titled, values.plan)
27
+ : saveSession(cfg, "PUT", path, body, values.plan));
28
+ const json = dropped.length ? { ...saved, dropped } : saved;
29
+ const { created, session } = json;
30
+ const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.`;
31
+ const json_ = values.json === true;
32
+ // Human mode prints the session line before uploads start; --json waits so stdout is one document.
33
+ if (!json_)
34
+ print({ data: json, text }, false);
35
+ if (!values.evidence?.length) {
36
+ if (json_)
37
+ print({ data: json, text }, true);
38
+ return;
39
+ }
40
+ const result = await uploadEvidence(cfg, id, values.evidence.map(parseEvidence));
41
+ if (json_)
42
+ print({ data: { ...json, evidence: result }, text }, true);
43
+ else
44
+ console.log(`Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`);
45
+ for (const s of result.skipped)
46
+ warn(`${s.path}: ${s.reason}`);
47
+ }
48
+ // The same fields, minus a derived title, for a session that exists; on a 404, the PUT that creates it, at cost 0 while the
49
+ // cost is unknown.
50
+ async function patchFirst(cfg, path, body, titled, guess) {
51
+ try {
52
+ const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
53
+ return { ...res, json: { created: false, ...res.json } };
54
+ }
55
+ catch (err) {
56
+ if (!(err instanceof CliError && err.status === 404))
57
+ throw err;
58
+ return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0 }, guess);
59
+ }
60
+ }
61
+ // <path>[::<caption>]
62
+ function parseEvidence(spec) {
63
+ const i = spec.indexOf("::");
64
+ return i < 0 ? { path: spec } : { path: spec.slice(0, i), caption: spec.slice(i + 2) || undefined };
65
+ }
@@ -0,0 +1,33 @@
1
+ import { CliError, request } from "../client.js";
2
+ import { mergeLabel, print } from "../output.js";
3
+ export async function session(cfg, values, positionals) {
4
+ if (positionals.length !== 1)
5
+ throw new CliError("usage: berliner session <session-id> [--json]", "usage");
6
+ const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(positionals[0])}`);
7
+ const s = json.session;
8
+ const fields = [
9
+ ["Session", s.id],
10
+ ["Title", s.title],
11
+ ["Repository", s.repository],
12
+ ["Task", [s.task_id, s.task_url].filter(Boolean).join(" ")],
13
+ ["Project", s.project],
14
+ ["Estimated cost", `$${s.cost_usd.toFixed(2)}`],
15
+ ["Model", s.model],
16
+ ["Plan", s.plan],
17
+ ["Started", s.started_at],
18
+ ["Ended", s.ended_at],
19
+ ["Duration (s)", s.duration_seconds],
20
+ ["Commits", s.commits],
21
+ ["Coverage", s.coverage],
22
+ ["CI", s.ci],
23
+ ["Merge", mergeLabel(s)],
24
+ ];
25
+ const lines = fields.filter(([, v]) => v !== null && v !== undefined && v !== "").map(([k, v]) => `${k}: ${v}`);
26
+ lines.push(`Pull requests: ${s.prs.length || "none"}`);
27
+ for (const p of s.prs)
28
+ lines.push(` ${p.url} merge=${p.merge_state} ci=${p.ci_state}${p.tracked ? "" : " (not tracked)"}`);
29
+ lines.push(`Evidence: ${s.evidence.length || "none"}`);
30
+ for (const e of s.evidence)
31
+ lines.push(` #${e.id} ${e.kind} ${e.filename}${e.caption ? ` - ${e.caption}` : ""}${e.status === "ready" ? "" : ` (${e.status})`}`);
32
+ print({ data: json, text: lines.join("\n") }, values.json === true);
33
+ }
@@ -0,0 +1,17 @@
1
+ import { request } from "../client.js";
2
+ import { mergeLabel, print } from "../output.js";
3
+ import { filterQuery } from "./query.js";
4
+ const usd = (n) => `$${n.toFixed(2)}`;
5
+ export async function sessions(cfg, values) {
6
+ const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
7
+ const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
8
+ for (const s of json.sessions)
9
+ rows.push([s.task_id ?? s.id, s.title, s.repository, usd(s.cost_usd), String(s.pr_count), s.ci, mergeLabel(s)]);
10
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
11
+ const lines = rows.map((r) => r
12
+ .map((c, i) => c.padEnd(widths[i]))
13
+ .join(" ")
14
+ .trimEnd());
15
+ const text = json.sessions.length ? `${lines.join("\n")}\n${json.sessions.length} of ${json.total} session(s).` : "No sessions.";
16
+ print({ data: json, text }, values.json === true);
17
+ }
@@ -0,0 +1,174 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { CliError, request } from "../client.js";
7
+ import { configPath, DEFAULT_URL, readConfig, readConfigFile, saveConfig } from "../config.js";
8
+ import { machineName, openBrowser, startConnect, waitForApproval } from "../setup/connect.js";
9
+ import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
10
+ import { claudeDir, codexDir, detectTools, onPath, opencodeDir, skillTargets } from "../setup/targets.js";
11
+ // The same command the web guide shows.
12
+ const TEST_REPORT = 'berliner report --session-id try-1 --title "First report" --repo you/app --cost 0.10 --model test --plan pro';
13
+ const tilde = (p) => (p.startsWith(`${homedir()}/`) ? `~${p.slice(homedir().length)}` : p);
14
+ export async function confirm(input, output) {
15
+ const rl = createInterface({ input, output });
16
+ // Ctrl+C and Ctrl+D reject the question without ending the line; both mean no.
17
+ const answer = await rl.question("Continue? [Y/n] ").catch(() => {
18
+ output.write("\n");
19
+ return "n";
20
+ });
21
+ rl.close();
22
+ return /^(y|yes)?$/i.test(answer.trim());
23
+ }
24
+ // The login of the saved token, or null when there is none or the server no longer accepts it.
25
+ async function savedLogin(url, token) {
26
+ if (!token)
27
+ return null;
28
+ try {
29
+ return (await request({ url, token }, "GET", "/v1/me")).json.github_login;
30
+ }
31
+ catch (err) {
32
+ if (err instanceof CliError && err.status === 401)
33
+ return null;
34
+ throw err;
35
+ }
36
+ }
37
+ export async function setup(env, values) {
38
+ const { url } = readConfig(env);
39
+ const login = await savedLogin(url, readConfigFile(env)?.token);
40
+ const tools = detectTools(env);
41
+ const targets = skillTargets(tools, env);
42
+ // One reporting hook per tool found, where the spec's "Hooks" table puts it.
43
+ const hookFiles = {
44
+ "claude-code": { path: join(claudeDir(env), "settings.json"), describe: describeClaudeHooks, install: installClaudeHooks },
45
+ codex: { path: join(codexDir(env), "hooks.json"), describe: describeCodexHooks, install: installCodexHooks },
46
+ opencode: { path: join(opencodeDir(env), "plugins", "berliner.js"), describe: describeOpenCodePlugin, install: installOpenCodePlugin },
47
+ };
48
+ const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id] }));
49
+ const version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
50
+ const install = [...(process.versions.bun ? ["bun", "add", "-g"] : ["npm", "install", "-g"]), `@paxnax/berliner@${version}`];
51
+ // The plan is data, built and shown before anything changes.
52
+ const steps = [
53
+ ...(login
54
+ ? [{ label: `Token: connected as ${login}, kept`, detail: [] }]
55
+ : [
56
+ { label: `${values["no-browser"] ? "Show a link to" : "Open"} ${url}/connect so you can approve this terminal`, detail: [] },
57
+ { label: `Save your token to ${tilde(configPath(env))} (readable only by you)`, detail: [] },
58
+ ]),
59
+ { label: `Install the berliner command: ${install.join(" ")}${values["no-install"] ? " (skipped by --no-install)" : ""}`, detail: [] },
60
+ {
61
+ label: "Add the Berliner skill for:",
62
+ detail: tools.map((tool) => {
63
+ const target = targets.find((t) => t.tool === tool.id);
64
+ const where = target ? `${tilde(target.path)}${target.exists ? " (replaces the existing copy)" : ""}` : "not found, skipped";
65
+ return `${tool.name.padEnd(14)}${where}`;
66
+ }),
67
+ },
68
+ {
69
+ label: "Add the Berliner reporting hook for:",
70
+ detail: tools.map((tool) => {
71
+ const hook = hooks.find((h) => h.id === tool.id);
72
+ if (!hook)
73
+ return `${tool.name.padEnd(14)}not found, skipped`;
74
+ const what = hook.describe(hook.path);
75
+ // Codex runs a new hook only once the user trusts it in Codex's terminal UI; setup leaves that check to Codex.
76
+ const approval = tool.id === "codex" && what === "adds a hook" ? ", which you trust by opening `codex` once in a terminal" : "";
77
+ return `${tool.name.padEnd(14)}${tilde(hook.path)} (${what}${approval})`;
78
+ }),
79
+ },
80
+ ];
81
+ const plan = steps.flatMap((s, i) => [` ${i + 1}. ${s.label}`, ...s.detail.map((d) => ` ${d}`)]);
82
+ console.log(["", "Berliner setup", "", "This will:", ...plan, ""].join("\n"));
83
+ if (!values.yes) {
84
+ if (!process.stdin.isTTY)
85
+ throw new CliError("There is no terminal to ask on. Run setup again with --yes to continue without asking.", "usage");
86
+ if (!(await confirm(process.stdin, process.stdout)))
87
+ throw new CliError("Cancelled. Nothing was changed.", "usage");
88
+ console.log();
89
+ }
90
+ if (!login) {
91
+ const name = machineName();
92
+ const req = await startConnect({ url, token: null }, name);
93
+ console.log(`Confirm this code in your browser: ${req.user_code}`);
94
+ if (values["no-browser"])
95
+ console.log(`Open ${req.verification_url} to approve. Waiting for approval...`);
96
+ else {
97
+ openBrowser(req.verification_url);
98
+ console.log(`Opened ${req.verification_url}. Waiting for approval...`);
99
+ }
100
+ const status = await waitForApproval({ url, token: null }, req.id, req.interval, req.expires_in);
101
+ if (status === "denied")
102
+ throw new CliError("The request was denied in the browser. Nothing was changed.", "http");
103
+ if (status === "expired")
104
+ throw new CliError("The request expired. Run setup again.", "http");
105
+ const me = await request({ url, token: req.token }, "GET", "/v1/me");
106
+ console.log(`✓ Approved by ${me.json.github_login}`);
107
+ const file = saveConfig(env, url === DEFAULT_URL ? { token: req.token } : { token: req.token, url });
108
+ console.log(`✓ Saved token "${name}" to ${tilde(file)}`);
109
+ }
110
+ let installFailed = false;
111
+ if (!values["no-install"]) {
112
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
113
+ installFailed = run.status !== 0;
114
+ if (!installFailed)
115
+ console.log(`✓ Installed berliner ${version}`);
116
+ else
117
+ console.error(`✗ Could not install the berliner command:\n${(run.error?.message ?? run.stdout + run.stderr).trimEnd()}`);
118
+ }
119
+ // A file that can't be written (read-only, say) is reported and skipped, so the other tools still get theirs.
120
+ let unwritten = false;
121
+ const cantWrite = (what, path, err) => {
122
+ unwritten = true;
123
+ console.error(`✗ ${what}: ${tilde(path)} can't be written (${err.message.split(",")[0]})`);
124
+ };
125
+ const skill = readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
126
+ for (const t of targets) {
127
+ const what = `${tools.find((tool) => tool.id === t.tool).name} skill`;
128
+ try {
129
+ mkdirSync(dirname(t.path), { recursive: true });
130
+ writeFileSync(t.path, skill);
131
+ console.log(`✓ ${what}: ${tilde(t.path)}`);
132
+ }
133
+ catch (err) {
134
+ cantWrite(what, t.path, err);
135
+ }
136
+ }
137
+ let codexAdded = false;
138
+ for (const h of hooks) {
139
+ let result;
140
+ try {
141
+ result = h.install(h.path);
142
+ }
143
+ catch (err) {
144
+ cantWrite(`${h.name} hook`, h.path, err);
145
+ continue;
146
+ }
147
+ if (result === "unreadable")
148
+ console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be read, so it was left unchanged`);
149
+ else
150
+ console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : ""}`);
151
+ codexAdded ||= h.id === "codex" && result === "added";
152
+ }
153
+ const summary = [
154
+ targets.length ? "Done. Your agents' sessions are now reported after each reply." : "Done. No agent tools were found, so no skill was added.",
155
+ ];
156
+ if (unwritten)
157
+ summary.push("Some files can't be written. Fix the ones marked ✗ above, then run setup again.");
158
+ if (codexAdded)
159
+ summary.push("Open `codex` once in a terminal and trust the Berliner hook; `codex exec` skips hooks you haven't approved. Once trusted, Codex sessions are reported automatically.");
160
+ // The hooks run `berliner` by name: skipped or failed installs, and a global bin directory missing from PATH, all stop them.
161
+ // npx and bunx put their package's node_modules/.bin first on setup's PATH, which the agent tools never see; a global
162
+ // install never lands in one.
163
+ const toolPath = env.PATH?.split(":")
164
+ .filter((dir) => !/(^|\/)node_modules\/\.bin\/?$/.test(dir))
165
+ .join(":");
166
+ if (hooks.length && !onPath("berliner", { PATH: toolPath }))
167
+ summary.push(`The hooks can't run until the berliner command is on your PATH. Install it with: ${install.join(" ")}`);
168
+ else if (installFailed)
169
+ summary.push(`Install the berliner command yourself: ${install.join(" ")}`);
170
+ if (env.BERLINER_TOKEN)
171
+ summary.push("BERLINER_TOKEN is set in this shell and overrides the saved token.");
172
+ summary.push(`Send a test report: ${TEST_REPORT}`);
173
+ console.log(`\n${summary.join("\n")}`);
174
+ }
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ // dist/commands/skill.js -> ../../skill/SKILL.md (the package's skill/ directory).
3
+ export function skill() {
4
+ process.stdout.write(readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8"));
5
+ }
@@ -0,0 +1,25 @@
1
+ import { request } from "../client.js";
2
+ import { print, subscriptionLabel } from "../output.js";
3
+ import { filterQuery } from "./query.js";
4
+ export async function summary(cfg, values) {
5
+ const { json } = await request(cfg, "GET", `/v1/summary${filterQuery(values)}`);
6
+ const s = json.summary;
7
+ const text = [
8
+ `Estimated spend: $${s.spend_usd.toFixed(2)}`,
9
+ `Sessions: ${s.sessions}`,
10
+ `PRs merged: ${s.prs_merged} of ${s.prs_opened} opened`,
11
+ `Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" : `$${s.cost_per_merged_pr.toFixed(2)}`}`,
12
+ `Commits: ${s.commits}`,
13
+ ...subscriptionLines(json.subscriptions),
14
+ ].join("\n");
15
+ print({ data: json, text }, values.json === true);
16
+ }
17
+ // One line per subscription, then the spend no listed plan covers; nothing when the user has no subscriptions.
18
+ function subscriptionLines(c) {
19
+ if (!c)
20
+ return [];
21
+ const lines = c.rows.map((r) => `${subscriptionLabel(r)}: $${r.spend_usd.toFixed(2)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
22
+ if (c.unmatched.sessions > 0)
23
+ lines.push(`On no listed plan: $${c.unmatched.spend_usd.toFixed(2)}, ${c.unmatched.sessions} session(s)`);
24
+ return lines;
25
+ }
@@ -0,0 +1,21 @@
1
+ import { CliError } from "../client.js";
2
+ import { print } from "../output.js";
3
+ import { fromTool, saveSession, sessionBody } from "./body.js";
4
+ import { inTool } from "./hook.js";
5
+ export async function update(cfg, values, positionals, env) {
6
+ const tool = inTool(env);
7
+ // Inside a tool the session id is the tool's, so a positional one is ignored like --session-id.
8
+ if (positionals.length !== 1 && !(tool && positionals.length === 0)) {
9
+ throw new CliError("usage: berliner update <session-id> [flags]; quote values that contain spaces", "usage");
10
+ }
11
+ if (values.evidence?.length)
12
+ throw new CliError("update does not upload evidence; use report --evidence", "usage");
13
+ if (tool)
14
+ values = await fromTool(tool, { ...values, "session-id": positionals[0] ?? values["session-id"] }, env, "update");
15
+ const id = tool ? tool.sessionId : positionals[0];
16
+ // A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
17
+ const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
18
+ const { json: saved, dropped } = await saveSession(cfg, "PATCH", `/v1/sessions/${encodeURIComponent(id)}`, body, values.plan);
19
+ const json = dropped.length ? { ...saved, dropped } : saved;
20
+ print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.` }, values.json === true);
21
+ }
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const DEFAULT_URL = "https://berliner.app";
5
+ export function configPath(env) {
6
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "berliner", "config.json");
7
+ }
8
+ export const stateDir = (env) => join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner");
9
+ export const cacheDir = (env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "berliner");
10
+ // A missing or unreadable file is the same as no file: the environment may still hold the token.
11
+ export function readConfigFile(env) {
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
14
+ if (typeof parsed !== "object" || parsed === null)
15
+ return null;
16
+ const { token, url } = parsed;
17
+ return { token: typeof token === "string" ? token : undefined, url: typeof url === "string" ? url : undefined };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function saveConfig(env, value) {
24
+ const file = configPath(env);
25
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
26
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
27
+ chmodSync(file, 0o600); // the mode above only applies when the file is created
28
+ return file;
29
+ }
30
+ export function readConfig(env) {
31
+ const file = readConfigFile(env);
32
+ return { url: env.BERLINER_URL || file?.url || DEFAULT_URL, token: env.BERLINER_TOKEN || file?.token || null, plan: env.BERLINER_PLAN || null };
33
+ }