@paxnax/berliner 0.2.1 → 0.4.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 CHANGED
@@ -21,7 +21,7 @@ With Bun, run `bunx --bun @paxnax/berliner@latest setup`. To install only the co
21
21
  npx @paxnax/berliner@latest setup
22
22
  ```
23
23
 
24
- It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner setup` again to update the skill.
24
+ It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI and the skill to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
25
25
 
26
26
  3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. Your agent adds the task, pull requests, coverage and evidence with `berliner report`, as the skill tells it to. Codex runs a new hook only once you trust it: open `codex` once in a terminal and trust the Berliner hook.
27
27
  4. On **Settings**, list the subscriptions you pay for. Overview then compares each one with the sessions that ran on it.
@@ -104,6 +104,8 @@ berliner report \
104
104
 
105
105
  Reporting again with the same `--session-id` replaces the values, adds PR URLs and skips evidence already uploaded. `--plan` is required unless `BERLINER_PLAN` is set.
106
106
 
107
+ Evidence is private: only you can see it, signed in. Add `--public` to also make each `--evidence` file, new or already uploaded, viewable by anyone with its link. The report then prints one line per file to paste into a pull request body: a Markdown image (`![caption](https://berliner.app/v1/public/evidence/<id>)`), or the bare URL for a video, which GitHub does not embed from other hosts. With `--json` they are in `evidence.published`. Browsers and GitHub may keep a copy of a public file for up to 5 minutes after you make it private again.
108
+
107
109
  Inside Claude Code, Codex or OpenCode, leave out `--session-id`, `--repo`, `--cost`, `--model` and `--plan`. The CLI fills in the session id, cost and model from the tool's own records and the repository from git, and ignores an explicit `--session-id`, `--cost` or `--model` with one warning. The plan comes from `BERLINER_PLAN` or your subscriptions, as above. A report there without `--title` keeps the existing title; a new session takes the first line of your first prompt, cut to 80 characters.
108
110
 
109
111
  Other commands:
@@ -114,7 +116,9 @@ Other commands:
114
116
  - `berliner session <session-id>`: show one session.
115
117
  - `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription.
116
118
  - `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
119
+ - `berliner evidence public <id>`: let anyone with the link view one file, and print its public URL and Markdown line. `berliner evidence private <id>` makes it private again. `berliner session` shows the ids, and the URL of each public file.
117
120
  - `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
121
+ - `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command and each skill file) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. Your token, config and hooks are never touched.
118
122
  - `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never prints, never fails and never makes the agent wait; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
119
123
 
120
124
  `report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure. The other commands exit 1 on failure.
package/dist/args.js CHANGED
@@ -16,12 +16,14 @@ const config = {
16
16
  coverage: { type: "string" },
17
17
  pr: { type: "string", multiple: true },
18
18
  evidence: { type: "string", multiple: true },
19
+ public: { type: "boolean" },
19
20
  json: { type: "boolean" },
20
21
  strict: { type: "boolean" },
21
22
  help: { type: "boolean" },
22
23
  yes: { type: "boolean" },
23
24
  "no-install": { type: "boolean" },
24
25
  "no-browser": { type: "boolean" },
26
+ "no-skill": { type: "boolean" },
25
27
  run: { type: "string" },
26
28
  ci: { type: "string" },
27
29
  merge: { type: "string" },
package/dist/client.js CHANGED
@@ -9,6 +9,7 @@ export class CliError extends Error {
9
9
  this.details = details;
10
10
  }
11
11
  }
12
+ export const absolute = (cfg, path) => cfg.url.replace(/\/+$/, "") + path;
12
13
  export async function request(cfg, method, path, body, opts = {}) {
13
14
  const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000, auth = true } = opts;
14
15
  if (auth && !cfg.token)
@@ -22,7 +23,7 @@ export async function request(cfg, method, path, body, opts = {}) {
22
23
  const canRetry = () => attempt < retries && Date.now() + backoffMs < deadline;
23
24
  let res;
24
25
  try {
25
- res = await fetch(cfg.url.replace(/\/+$/, "") + path, {
26
+ res = await fetch(absolute(cfg, path), {
26
27
  method,
27
28
  headers: {
28
29
  ...(auth ? { Authorization: `Bearer ${cfg.token}` } : {}),
@@ -8,7 +8,7 @@ export async function del(cfg, values, positionals) {
8
8
  if (evidenceId) {
9
9
  // Evidence ids are global, so the session named on the command line must actually hold this one.
10
10
  const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(id)}`);
11
- if (!json.session.evidence.some((e) => String(e.id) === evidenceId)) {
11
+ if (!json.session.evidence.some((e) => e.id === evidenceId)) {
12
12
  throw new CliError(`evidence ${evidenceId} is not part of session ${id}`, "usage");
13
13
  }
14
14
  }
@@ -0,0 +1,14 @@
1
+ import { absolute, CliError, request } from "../client.js";
2
+ import { embed, print } from "../output.js";
3
+ export async function evidence(cfg, values, positionals) {
4
+ const [action, id] = positionals;
5
+ if (positionals.length !== 2 || (action !== "public" && action !== "private")) {
6
+ throw new CliError("usage: berliner evidence public|private <id> [--json]", "usage");
7
+ }
8
+ const { json } = await request(cfg, "PATCH", `/v1/evidence/${encodeURIComponent(id)}`, { public: action === "public" });
9
+ const e = json.evidence;
10
+ const url = e.public_url && absolute(cfg, e.public_url);
11
+ const markdown = url && embed(e, url);
12
+ const text = !url ? `Evidence ${e.id} is private.` : markdown === url ? url : `${url}\n${markdown}`;
13
+ print({ data: json, text }, values.json === true);
14
+ }
@@ -17,6 +17,8 @@ export async function report(cfg, values, positionals, env) {
17
17
  const missing = required.filter((flag) => values[flag] === undefined && !(flag === "plan" && cfg.plan) && !(tool && (flag === "plan" || flag === "cost")));
18
18
  if (missing.length)
19
19
  throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
20
+ if (values.public && !values.evidence?.length)
21
+ throw new CliError("--public needs --evidence", "usage");
20
22
  const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
21
23
  const id = values["session-id"];
22
24
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
@@ -37,11 +39,14 @@ export async function report(cfg, values, positionals, env) {
37
39
  print({ data: json, text }, true);
38
40
  return;
39
41
  }
40
- const result = await uploadEvidence(cfg, id, values.evidence.map(parseEvidence));
42
+ const result = await uploadEvidence(cfg, id, values.evidence.map(parseEvidence), undefined, values.public === true);
41
43
  if (json_)
42
44
  print({ data: { ...json, evidence: result }, text }, true);
43
- else
45
+ else {
44
46
  console.log(`Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`);
47
+ for (const p of result.published ?? [])
48
+ console.log(p.markdown);
49
+ }
45
50
  for (const s of result.skipped)
46
51
  warn(`${s.path}: ${s.reason}`);
47
52
  }
@@ -1,4 +1,4 @@
1
- import { CliError, request } from "../client.js";
1
+ import { absolute, CliError, request } from "../client.js";
2
2
  import { mergeLabel, print } from "../output.js";
3
3
  export async function session(cfg, values, positionals) {
4
4
  if (positionals.length !== 1)
@@ -28,6 +28,6 @@ export async function session(cfg, values, positionals) {
28
28
  lines.push(` ${p.url} merge=${p.merge_state} ci=${p.ci_state}${p.tracked ? "" : " (not tracked)"}`);
29
29
  lines.push(`Evidence: ${s.evidence.length || "none"}`);
30
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})`}`);
31
+ lines.push(` ${e.id} ${e.kind} ${e.filename}${e.caption ? ` - ${e.caption}` : ""}${e.status === "ready" ? "" : ` (${e.status})`}${e.public_url ? ` public ${absolute(cfg, e.public_url)}` : ""}`);
32
32
  print({ data: json, text: lines.join("\n") }, values.json === true);
33
33
  }
@@ -10,17 +10,23 @@ import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, instal
10
10
  import { claudeDir, codexDir, detectTools, onPath, opencodeDir, skillTargets } from "../setup/targets.js";
11
11
  // The same command the web guide shows.
12
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) {
13
+ export const tilde = (p) => (p.startsWith(`${homedir()}/`) ? `~${p.slice(homedir().length)}` : p);
14
+ export async function confirm(input, output, question = "Continue? [Y/n] ") {
15
15
  const rl = createInterface({ input, output });
16
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(() => {
17
+ const answer = await rl.question(question).catch(() => {
18
18
  output.write("\n");
19
19
  return "n";
20
20
  });
21
21
  rl.close();
22
22
  return /^(y|yes)?$/i.test(answer.trim());
23
23
  }
24
+ // Pinned to one version, so the command shown is the one run.
25
+ export const installCommand = (version) => [...(process.versions.bun ? ["bun", "add", "-g"] : ["npm", "install", "-g"]), `@paxnax/berliner@${version}`];
26
+ export function showPlan(title, steps) {
27
+ const plan = steps.flatMap((s, i) => [` ${i + 1}. ${s.label}`, ...s.detail.map((d) => ` ${d}`)]);
28
+ console.log(["", title, "", "This will:", ...plan, ""].join("\n"));
29
+ }
24
30
  // The login of the saved token, or null when there is none or the server no longer accepts it.
25
31
  async function savedLogin(url, token) {
26
32
  if (!token)
@@ -47,7 +53,7 @@ export async function setup(env, values) {
47
53
  };
48
54
  const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id] }));
49
55
  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}`];
56
+ const install = installCommand(version);
51
57
  // The plan is data, built and shown before anything changes.
52
58
  const steps = [
53
59
  ...(login
@@ -78,8 +84,7 @@ export async function setup(env, values) {
78
84
  }),
79
85
  },
80
86
  ];
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"));
87
+ showPlan("Berliner setup", steps);
83
88
  if (!values.yes) {
84
89
  if (!process.stdin.isTTY)
85
90
  throw new CliError("There is no terminal to ask on. Run setup again with --yes to continue without asking.", "usage");
@@ -10,6 +10,8 @@ export async function update(cfg, values, positionals, env) {
10
10
  }
11
11
  if (values.evidence?.length)
12
12
  throw new CliError("update does not upload evidence; use report --evidence", "usage");
13
+ if (values.public)
14
+ throw new CliError("update does not publish evidence; use report --evidence --public, or berliner evidence public <id>", "usage");
13
15
  if (tool)
14
16
  values = await fromTool(tool, { ...values, "session-id": positionals[0] ?? values["session-id"] }, env, "update");
15
17
  const id = tool ? tool.sessionId : positionals[0];
@@ -0,0 +1,116 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { CliError } from "../client.js";
5
+ import { detectTools, skillTargets } from "../setup/targets.js";
6
+ import { confirm, installCommand, showPlan, tilde } from "./setup.js";
7
+ async function latestVersion(env) {
8
+ const registry = (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
9
+ try {
10
+ const res = await fetch(`${registry}/@paxnax/berliner/latest`);
11
+ if (!res.ok)
12
+ throw new Error(`HTTP ${res.status}`);
13
+ const { version } = (await res.json());
14
+ if (typeof version !== "string")
15
+ throw new Error("no version in the reply");
16
+ return version;
17
+ }
18
+ catch (err) {
19
+ throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
20
+ }
21
+ }
22
+ // The skill text comes from the release being installed, so the plan's "already up to date" and what is written match it.
23
+ function skillOf(version, bundled) {
24
+ if (bundled)
25
+ return readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
26
+ // --bun keeps the CLI on Bun: its shebang prefers node when both exist.
27
+ const [cmd, ...pre] = process.versions.bun ? ["bunx", "--bun"] : ["npx", "--yes"];
28
+ const run = spawnSync(cmd, [...pre, `@paxnax/berliner@${version}`, "skill"], { encoding: "utf8" });
29
+ if (run.status !== 0 || !run.stdout)
30
+ throw new CliError(`Could not read the skill of berliner ${version} (${(run.error?.message ?? run.stderr).trim() || `exited with ${run.status}`}). Nothing was changed.`, "network");
31
+ return run.stdout;
32
+ }
33
+ // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
34
+ const newer = (a, b) => {
35
+ const [x, y] = [a, b].map((v) => v.split("-")[0].split(".").map(Number));
36
+ const i = x.findIndex((n, j) => n !== y[j]);
37
+ return i >= 0 && x[i] > y[i];
38
+ };
39
+ const sameAs = (path, text) => {
40
+ try {
41
+ return readFileSync(path, "utf8") === text;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ };
47
+ // Only the command and the skill: the token, config and hooks are setup's, and stay as they are.
48
+ export async function upgrade(env, values) {
49
+ const current = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
50
+ const latest = await latestVersion(env);
51
+ // A CLI ahead of the registry (a local build, a release still propagating) is never taken backwards, nor is its skill.
52
+ const installing = newer(latest, current);
53
+ const target = installing ? latest : current;
54
+ const install = installCommand(latest);
55
+ const tools = detectTools(env);
56
+ const targets = values["no-skill"] ? [] : skillTargets(tools, env);
57
+ const skill = targets.length ? skillOf(target, !installing) : "";
58
+ let stale = targets.filter((t) => !sameAs(t.path, skill));
59
+ if (!installing && !stale.length) {
60
+ console.log(`Already up to date. berliner ${current} is ${current === latest ? "the latest release" : `newer than the latest release (${latest})`}.`);
61
+ return;
62
+ }
63
+ showPlan("Berliner upgrade", [
64
+ {
65
+ label: installing
66
+ ? `Upgrade the berliner command from ${current} to ${latest}: ${install.join(" ")}`
67
+ : current === latest
68
+ ? `berliner ${current} is already the latest, nothing to install`
69
+ : `berliner ${current} is newer than the latest release (${latest}), nothing to install`,
70
+ detail: [],
71
+ },
72
+ values["no-skill"]
73
+ ? { label: "Leave the Berliner skill as it is (--no-skill)", detail: [] }
74
+ : {
75
+ label: `Upgrade the Berliner skill to ${target} for:`,
76
+ detail: tools.map((tool) => {
77
+ const target = targets.find((t) => t.tool === tool.id);
78
+ const where = target ? `${tilde(target.path)}${stale.includes(target) ? "" : " (already up to date)"}` : "not found, skipped";
79
+ return `${tool.name.padEnd(14)}${where}`;
80
+ }),
81
+ },
82
+ ]);
83
+ if (!values.yes) {
84
+ if (!process.stdin.isTTY)
85
+ throw new CliError("There is no terminal to ask on. Run upgrade 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
+ // Without an install, the skill is all Continue covered.
89
+ if (installing && stale.length && !(await confirm(process.stdin, process.stdout, "Upgrade the skill too? [Y/n] ")))
90
+ stale = [];
91
+ console.log();
92
+ }
93
+ if (installing) {
94
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
95
+ if (run.status !== 0) {
96
+ console.error(`✗ Could not install the berliner command:\n${(run.error?.message ?? run.stdout + run.stderr).trimEnd()}`);
97
+ throw new CliError(`The skill was left as it is. Install the command yourself, then run upgrade again: ${install.join(" ")}`, "usage");
98
+ }
99
+ console.log(`✓ Installed berliner ${latest}`);
100
+ }
101
+ // A file that can't be written (read-only, say) is reported and skipped, so the other tools still get theirs.
102
+ let unwritten = false;
103
+ for (const t of stale) {
104
+ const what = `${tools.find((tool) => tool.id === t.tool).name} skill`;
105
+ try {
106
+ mkdirSync(dirname(t.path), { recursive: true });
107
+ writeFileSync(t.path, skill);
108
+ console.log(`✓ ${what}: ${tilde(t.path)}`);
109
+ }
110
+ catch (err) {
111
+ unwritten = true;
112
+ console.error(`✗ ${what}: ${tilde(t.path)} can't be written (${err.message.split(",")[0]})`);
113
+ }
114
+ }
115
+ console.log(unwritten ? "\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again." : "\nDone.");
116
+ }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { parse } from "./args.js";
4
4
  import { CliError } from "./client.js";
5
5
  import { del } from "./commands/delete.js";
6
+ import { evidence } from "./commands/evidence.js";
6
7
  import { hook } from "./commands/hook.js";
7
8
  import { report } from "./commands/report.js";
8
9
  import { session } from "./commands/session.js";
@@ -11,6 +12,7 @@ import { setup } from "./commands/setup.js";
11
12
  import { skill } from "./commands/skill.js";
12
13
  import { summary } from "./commands/summary.js";
13
14
  import { update } from "./commands/update.js";
15
+ import { upgrade } from "./commands/upgrade.js";
14
16
  import { readConfig } from "./config.js";
15
17
  import { warn } from "./output.js";
16
18
  const usage = `Usage: berliner <command> [flags]
@@ -19,7 +21,7 @@ Commands:
19
21
  setup connect this terminal, install the CLI and skill [--yes --no-install --no-browser]
20
22
  report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p>
21
23
  [--task --task-url --project --started-at --ended-at --commits --coverage]
22
- [--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--json] [--strict]
24
+ [--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--public] [--json] [--strict]
23
25
  inside Claude Code, Codex or OpenCode the session id, cost, model and repo are filled in,
24
26
  and the server picks the plan (BERLINER_PLAN overrides it)
25
27
  update <session-id> same flags as report, all optional (no session id inside those tools)
@@ -27,6 +29,9 @@ Commands:
27
29
  session <session-id> [--json]
28
30
  summary same filters as sessions [--json]
29
31
  delete <session-id> [--evidence <id>] [--yes]
32
+ evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
33
+ evidence private <id> make it viewable only by you again [--json]
34
+ upgrade install the latest CLI and skill [--yes --no-skill]
30
35
  skill print the bundled SKILL.md
31
36
  hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
32
37
 
@@ -34,7 +39,7 @@ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
34
39
  const args = parse(process.argv.slice(2));
35
40
  // Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
36
41
  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"]);
42
+ const handled = new Set(["setup", "report", "update", "sessions", "session", "summary", "delete", "evidence", "skill", "hook", "upgrade"]);
38
43
  const cfg = () => readConfig(process.env);
39
44
  try {
40
45
  if (args.values.help)
@@ -58,8 +63,12 @@ try {
58
63
  await summary(cfg(), args.values);
59
64
  else if (args.command === "delete")
60
65
  await del(cfg(), args.values, args.positionals);
66
+ else if (args.command === "evidence")
67
+ await evidence(cfg(), args.values, args.positionals);
61
68
  else if (args.command === "skill")
62
69
  skill();
70
+ else if (args.command === "upgrade")
71
+ await upgrade(process.env, args.values);
63
72
  else
64
73
  throw new CliError(args.command ? `unknown command "${args.command}"` : "missing command", "usage");
65
74
  }
package/dist/output.js CHANGED
@@ -5,6 +5,8 @@ export function print(result, json) {
5
5
  export function warn(msg) {
6
6
  console.error(`berliner: ${msg.replace(/\s*\n\s*/g, " ")}`);
7
7
  }
8
+ // GitHub renders an image from Markdown but not a video from another host, so a video is its bare URL.
9
+ export const embed = (e, url) => e.kind === "video" ? url : `![${(e.caption || e.filename).replace(/[[\]]/g, "\\$&")}](${url})`;
8
10
  // Same wording as the web's mergeLabel (app/src/web/format.ts).
9
11
  const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
10
12
  export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
package/dist/upload.js CHANGED
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { open, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { CliError, request } from "./client.js";
5
+ import { absolute, CliError, request } from "./client.js";
6
+ import { embed } from "./output.js";
6
7
  const TYPES = {
7
8
  ".png": "image/png",
8
9
  ".jpg": "image/jpeg",
@@ -15,23 +16,37 @@ const TYPES = {
15
16
  };
16
17
  // Evidence uploads are exempt from the 15 s report deadline (ruling R25).
17
18
  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: [] };
19
+ export async function uploadEvidence(cfg, sessionId, files, budget = LONG, publish = false) {
20
+ const result = { uploaded: 0, existing: 0, skipped: [], ...(publish ? { published: [] } : {}) };
20
21
  let unreachable = null;
21
22
  for (const file of files) {
22
23
  if (unreachable) {
23
24
  result.skipped.push({ path: file.path, reason: unreachable });
24
25
  continue;
25
26
  }
27
+ let stage = "";
26
28
  try {
27
- if (await uploadOne(cfg, sessionId, file, budget))
29
+ const { created, id } = await uploadOne(cfg, sessionId, file, budget);
30
+ if (created)
28
31
  result.uploaded++;
29
32
  else
30
33
  result.existing++;
34
+ if (result.published) {
35
+ stage = "not made public: ";
36
+ const { json } = await request(cfg, "PATCH", `/v1/evidence/${encodeURIComponent(id)}`, { public: true }, budget);
37
+ const url = absolute(cfg, json.evidence.public_url);
38
+ result.published.push({
39
+ path: file.path,
40
+ id,
41
+ url,
42
+ // The caption given now, since the server keeps an existing file's first caption.
43
+ markdown: embed({ ...json.evidence, caption: file.caption ?? json.evidence.caption }, url),
44
+ });
45
+ }
31
46
  }
32
47
  catch (err) {
33
48
  // One bad file never fails the report.
34
- const reason = err instanceof Error ? err.message : String(err);
49
+ const reason = stage + (err instanceof Error ? err.message : String(err));
35
50
  result.skipped.push({ path: file.path, reason });
36
51
  // The next file would wait out the same budget against the same server (anti-goal 1).
37
52
  if (err instanceof CliError && err.kind === "network")
@@ -40,7 +55,7 @@ export async function uploadEvidence(cfg, sessionId, files, budget = LONG) {
40
55
  }
41
56
  return result;
42
57
  }
43
- // Resolves true when uploaded, false when the server already had the content.
58
+ // created is false when the server already had the content.
44
59
  async function uploadOne(cfg, sessionId, file, budget) {
45
60
  const contentType = TYPES[path.extname(file.path).toLowerCase()];
46
61
  if (!contentType)
@@ -60,9 +75,10 @@ async function uploadOne(cfg, sessionId, file, budget) {
60
75
  sha256: hash.digest("hex"),
61
76
  caption: file.caption,
62
77
  }, budget);
78
+ const id = json.evidence.id;
63
79
  if (json.status === "exists")
64
- return false;
65
- const base = `/v1/evidence/${json.evidence.id}`;
80
+ return { created: false, id };
81
+ const base = `/v1/evidence/${id}`;
66
82
  const handle = await open(file.path);
67
83
  try {
68
84
  const slice = async (start, length) => {
@@ -90,5 +106,5 @@ async function uploadOne(cfg, sessionId, file, budget) {
90
106
  finally {
91
107
  await handle.close();
92
108
  }
93
- return true;
109
+ return { created: true, id };
94
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "type": "module",
6
6
  "engines": {
package/skill/SKILL.md CHANGED
@@ -50,10 +50,21 @@ Report again with the same `--session-id` whenever something changes.
50
50
  - `--coverage <percent>`: from the test run's coverage summary.
51
51
  - `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
52
52
  - `--evidence <path>[::caption]`: repeat per screenshot or video.
53
+ - `--public`: with `--evidence`, also make those files viewable without sign-in. See below.
53
54
  - `--task <id>`, `--task-url <url>`, `--project <name>`, `--started-at <ISO>`, `--ended-at <ISO>`.
54
55
 
55
56
  Quote any value that contains spaces.
56
57
 
58
+ ## Evidence in a pull request body
59
+
60
+ Evidence is private by default: only the user can see it, signed in. GitHub can show only public files. To embed screenshots or videos in a PR body, report them with `--public`:
61
+
62
+ ```sh
63
+ berliner report --evidence "./shots/after.png::Login page after the fix" --public
64
+ ```
65
+
66
+ After the evidence line, it prints one line per file: `![Login page after the fix](https://berliner.app/v1/public/evidence/<id>)` for an image, the bare URL for a video. Paste those lines into the PR body as printed. Anyone with a link can view the file, so publish only what belongs in the PR. `berliner evidence private <id>` makes one private again; `berliner evidence public <id>` publishes one already uploaded. `berliner session <id>` lists the ids.
67
+
57
68
  ## Environment
58
69
 
59
70
  `berliner setup` stores the API token in a config file, so `BERLINER_TOKEN` is only needed to override it (a token from the Settings page at https://berliner.app). `BERLINER_URL` overrides the server address. `BERLINER_PLAN`, when the user sets it, replaces any `--plan`.
@@ -68,7 +79,7 @@ The session is saved first, then evidence is uploaded. Allow at least 10 minutes
68
79
 
69
80
  - Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.`
70
81
  - A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work (`--strict` makes failures exit 1). Re-run the same command; it is safe to repeat.
71
- - `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given.
82
+ - `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given, and `published` (`path`, `id`, `url`, `markdown`) with `--public`.
72
83
  - To change a field later without resending everything, use `berliner update --commits 5` inside the tools, or `berliner update <session-id> --commits 5` outside them.
73
84
 
74
85
  ## Examples