@paxnax/berliner 0.2.1 → 0.3.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 +3 -0
- package/dist/args.js +1 -0
- package/dist/client.js +2 -1
- package/dist/commands/delete.js +1 -1
- package/dist/commands/evidence.js +14 -0
- package/dist/commands/report.js +7 -2
- package/dist/commands/session.js +2 -2
- package/dist/commands/update.js +2 -0
- package/dist/index.js +7 -2
- package/dist/output.js +2 -0
- package/dist/upload.js +25 -9
- package/package.json +1 -1
- package/skill/SKILL.md +12 -1
package/README.md
CHANGED
|
@@ -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 (``), 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,6 +116,7 @@ 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.
|
|
118
121
|
- `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
122
|
|
package/dist/args.js
CHANGED
|
@@ -16,6 +16,7 @@ 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" },
|
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
|
|
26
|
+
res = await fetch(absolute(cfg, path), {
|
|
26
27
|
method,
|
|
27
28
|
headers: {
|
|
28
29
|
...(auth ? { Authorization: `Bearer ${cfg.token}` } : {}),
|
package/dist/commands/delete.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -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(`
|
|
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
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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];
|
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";
|
|
@@ -19,7 +20,7 @@ Commands:
|
|
|
19
20
|
setup connect this terminal, install the CLI and skill [--yes --no-install --no-browser]
|
|
20
21
|
report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p>
|
|
21
22
|
[--task --task-url --project --started-at --ended-at --commits --coverage]
|
|
22
|
-
[--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--json] [--strict]
|
|
23
|
+
[--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--public] [--json] [--strict]
|
|
23
24
|
inside Claude Code, Codex or OpenCode the session id, cost, model and repo are filled in,
|
|
24
25
|
and the server picks the plan (BERLINER_PLAN overrides it)
|
|
25
26
|
update <session-id> same flags as report, all optional (no session id inside those tools)
|
|
@@ -27,6 +28,8 @@ Commands:
|
|
|
27
28
|
session <session-id> [--json]
|
|
28
29
|
summary same filters as sessions [--json]
|
|
29
30
|
delete <session-id> [--evidence <id>] [--yes]
|
|
31
|
+
evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
|
|
32
|
+
evidence private <id> make it viewable only by you again [--json]
|
|
30
33
|
skill print the bundled SKILL.md
|
|
31
34
|
hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
|
|
32
35
|
|
|
@@ -34,7 +37,7 @@ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
|
|
|
34
37
|
const args = parse(process.argv.slice(2));
|
|
35
38
|
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
36
39
|
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"]);
|
|
40
|
+
const handled = new Set(["setup", "report", "update", "sessions", "session", "summary", "delete", "evidence", "skill", "hook"]);
|
|
38
41
|
const cfg = () => readConfig(process.env);
|
|
39
42
|
try {
|
|
40
43
|
if (args.values.help)
|
|
@@ -58,6 +61,8 @@ try {
|
|
|
58
61
|
await summary(cfg(), args.values);
|
|
59
62
|
else if (args.command === "delete")
|
|
60
63
|
await del(cfg(), args.values, args.positionals);
|
|
64
|
+
else if (args.command === "evidence")
|
|
65
|
+
await evidence(cfg(), args.values, args.positionals);
|
|
61
66
|
else if (args.command === "skill")
|
|
62
67
|
skill();
|
|
63
68
|
else
|
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
|
-
|
|
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
|
-
//
|
|
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/${
|
|
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
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: `` 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
|