@paxnax/berliner 0.7.0 → 0.8.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 upgrade` to update the CLI, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
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, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is. Once a day, the first `berliner` command or agent session start checks for a newer release and says so.
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. They also fill in a ticket from a Linear or Jira URL in your prompts while the session has none, and refresh up to 5 open pull requests the Berliner GitHub App doesn't track with your `gh` (skipped when `gh` is missing or signed out). When a session starts, the hook reminds your agent to name the session and report its ticket. Your agent adds the title, ticket, pull requests, coverage and evidence with `berliner report` and `berliner update`, 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.
@@ -121,7 +121,7 @@ Other commands:
121
121
  - `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
122
122
  - `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.
123
123
  - `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
124
- - `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched.
124
+ - `berliner upgrade [--yes] [--no-skill] [--no-hooks]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is, and `--no-hooks` leaves your hooks as they are. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched. When it updated a skill or hook, its last line names the tools to start a new session in so they load it.
125
125
  - `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never fails and never makes the agent wait, and prints only the reminder when a session starts; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
126
126
 
127
127
  `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, including a file that could not be uploaded or made public; the session is still saved. The other commands exit 1 on failure.
package/dist/args.js CHANGED
@@ -28,6 +28,7 @@ const config = {
28
28
  "no-install": { type: "boolean" },
29
29
  "no-browser": { type: "boolean" },
30
30
  "no-skill": { type: "boolean" },
31
+ "no-hooks": { type: "boolean" },
31
32
  none: { type: "boolean" },
32
33
  all: { type: "boolean" },
33
34
  run: { type: "string" },
@@ -13,6 +13,7 @@ import { estimateCost, loadPrices } from "../prices.js";
13
13
  import { noteRefusal, resolveTeam, teamWarning } from "../team.js";
14
14
  import { checkTests } from "../tests/check.js";
15
15
  import { nudge } from "../tests/nudge.js";
16
+ import { upgradeNotice } from "../version.js";
16
17
  // Same text in the OpenCode plugin (cli/opencode/berliner.js).
17
18
  const REMINDER = 'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
18
19
  const TOOLS = ["claude-code", "codex", "opencode"];
@@ -103,9 +104,11 @@ async function start(tool, env) {
103
104
  }
104
105
  else {
105
106
  // In the agent's context from the start, whether or not the skill triggers; after a compaction or /clear it may have lost
106
- // the task and PRs it hasn't reported yet.
107
- if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source))
108
- return console.log(REMINDER);
107
+ // the task and PRs it hasn't reported yet. The day's upgrade check goes with it, so the agent can tell the user.
108
+ if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source)) {
109
+ const notice = await upgradeNotice(env);
110
+ return console.log(notice ? `${REMINDER}\n${notice}` : REMINDER);
111
+ }
109
112
  if (!REPORT_EVENTS.includes(p.hook_event_name))
110
113
  return;
111
114
  id = p.session_id;
@@ -6,24 +6,9 @@ import { gunzipSync } from "node:zlib";
6
6
  import { CliError } from "../client.js";
7
7
  import { hookPath, updatedHooks } from "../setup/hooks.js";
8
8
  import { detectTools, skillTargets } from "../setup/targets.js";
9
+ import { currentVersion, latestVersion, newer, registryOf } from "../version.js";
9
10
  import { askNudge } from "./nudge.js";
10
11
  import { confirm, installCommand, showPlan, tilde } from "./setup.js";
11
- const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
12
- async function latestVersion(env) {
13
- const registry = registryOf(env);
14
- try {
15
- const res = await fetch(`${registry}/@paxnax/berliner/latest`);
16
- if (!res.ok)
17
- throw new Error(`HTTP ${res.status}`);
18
- const { version } = (await res.json());
19
- if (typeof version !== "string")
20
- throw new Error("no version in the reply");
21
- return version;
22
- }
23
- catch (err) {
24
- throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
25
- }
26
- }
27
12
  // The skill and the OpenCode plugin come from the release being installed, so the plan's "already up to date" and what is
28
13
  // written match it. They are read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc
29
14
  // and node_modules.
@@ -63,12 +48,6 @@ async function releaseFiles(version, bundled, env) {
63
48
  throw new CliError(`Could not read the skill and OpenCode plugin of berliner ${version} (${err.message}). Nothing was changed.`, "network");
64
49
  }
65
50
  }
66
- // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
67
- const newer = (a, b) => {
68
- const [x, y] = [a, b].map((v) => v.split("-")[0].split(".").map(Number));
69
- const i = x.findIndex((n, j) => n !== y[j]);
70
- return i >= 0 && x[i] > y[i];
71
- };
72
51
  const sameAs = (path, text) => {
73
52
  try {
74
53
  return readFileSync(path, "utf8") === text;
@@ -85,7 +64,7 @@ const HOOK_CHANGE = {
85
64
  };
86
65
  // The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
87
66
  export async function upgrade(env, values) {
88
- const current = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
67
+ const current = currentVersion();
89
68
  const latest = await latestVersion(env);
90
69
  // A CLI ahead of the registry (a local build, a release still propagating) is never taken backwards, nor is its skill.
91
70
  const installing = newer(latest, current);
@@ -94,11 +73,12 @@ export async function upgrade(env, values) {
94
73
  const tools = detectTools(env);
95
74
  const targets = values["no-skill"] ? [] : skillTargets(tools, env);
96
75
  // The plugin too, so --no-skill still brings it up to date.
97
- const release = targets.length || tools.some((t) => t.id === "opencode" && t.found) ? await releaseFiles(target, !installing, env) : null;
76
+ const plugin = !values["no-hooks"] && tools.some((t) => t.id === "opencode" && t.found);
77
+ const release = targets.length || plugin ? await releaseFiles(target, !installing, env) : null;
98
78
  const skill = release?.skill ?? "";
99
79
  let stale = targets.filter((t) => !sameAs(t.path, skill));
100
80
  // Computed now, for the plan.
101
- const hooks = tools.flatMap((tool) => {
81
+ const hooks = (values["no-hooks"] ? [] : tools).flatMap((tool) => {
102
82
  const path = hookPath(tool.id, env);
103
83
  const text = updatedHooks(tool.id, path, release?.plugin);
104
84
  return text === null ? [] : [{ ...tool, path, text }];
@@ -126,13 +106,15 @@ export async function upgrade(env, values) {
126
106
  return `${tool.name.padEnd(14)}${where}`;
127
107
  }),
128
108
  },
129
- ...(hooks.length
130
- ? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
131
- : []),
109
+ ...(values["no-hooks"]
110
+ ? [{ label: "Leave the Berliner hooks as they are (--no-hooks)", detail: [] }]
111
+ : hooks.length
112
+ ? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
113
+ : []),
132
114
  ]);
133
115
  if (!values.yes) {
134
116
  if (!process.stdin.isTTY)
135
- throw new CliError("There is no terminal to ask on. Run upgrade again with --yes to continue without asking.", "usage");
117
+ throw new CliError("There is no terminal to ask on, so nothing was changed. To go ahead without asking, run upgrade again with --yes. If you are an AI agent, show the user this plan and get their approval first.", "usage");
136
118
  if (!(await confirm(process.stdin, process.stdout)))
137
119
  throw new CliError("Cancelled. Nothing was changed.", "usage");
138
120
  // Without an install, the skill is all Continue covered.
@@ -152,12 +134,15 @@ export async function upgrade(env, values) {
152
134
  }
153
135
  // A file that can't be written (read-only, say) is reported and skipped, so the other tools still get theirs.
154
136
  let unwritten = false;
137
+ // The tools that load what was written, for the reload line.
138
+ const reload = { skill: new Set(), hooks: new Set() };
155
139
  for (const t of stale) {
156
140
  const what = `${tools.find((tool) => tool.id === t.tool).name} skill`;
157
141
  try {
158
142
  mkdirSync(dirname(t.path), { recursive: true });
159
143
  writeFileSync(t.path, skill);
160
144
  console.log(`✓ ${what}: ${tilde(t.path)}`);
145
+ reload.skill.add(t.tool);
161
146
  }
162
147
  catch (err) {
163
148
  unwritten = true;
@@ -175,11 +160,16 @@ export async function upgrade(env, values) {
175
160
  // In place, so a settings file that is a symlink stays one.
176
161
  writeFileSync(h.path, text);
177
162
  console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
163
+ reload.hooks.add(h.id);
178
164
  }
179
165
  catch (err) {
180
166
  unwritten = true;
181
167
  console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be written (${err.message.split(",")[0]})`);
182
168
  }
183
169
  }
184
- console.log(unwritten ? "\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again." : "\nDone.");
170
+ // A running agent keeps the skill and hooks it started with; the command itself is new from the next call.
171
+ const names = tools.filter((t) => reload.skill.has(t.id) || reload.hooks.has(t.id)).map((t) => t.name);
172
+ const what = [reload.skill.size && "skill", reload.hooks.size && "hooks"].filter(Boolean).join(" and ");
173
+ const again = names.length ? ` Start a new session in ${names.join(", ").replace(/, ([^,]*)$/, " and $1")} to load the updated ${what}.` : "";
174
+ console.log(unwritten ? `\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again.${again}` : `\nDone.${again}`);
185
175
  }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { update } from "./commands/update.js";
19
19
  import { upgrade } from "./commands/upgrade.js";
20
20
  import { readConfig } from "./config.js";
21
21
  import { warn } from "./output.js";
22
+ import { upgradeNotice } from "./version.js";
22
23
  const usage = `Usage: berliner <command> [flags]
23
24
 
24
25
  Commands:
@@ -43,7 +44,7 @@ Commands:
43
44
  delete <session-id> [--evidence <id>] [--yes]
44
45
  evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
45
46
  evidence private <id> make it viewable only by you again [--json]
46
- upgrade install the latest CLI and skill [--yes --no-skill]
47
+ upgrade install the latest CLI, skill and hooks [--yes --no-skill --no-hooks]
47
48
  skill print the bundled SKILL.md
48
49
  hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
49
50
 
@@ -114,3 +115,9 @@ catch (err) {
114
115
  console.error(usage);
115
116
  process.exitCode = softFail ? 0 : 1;
116
117
  }
118
+ // The hook carries it in the session-start reminder instead, and upgrade looks for itself.
119
+ if (args.command !== "hook" && args.command !== "upgrade") {
120
+ const notice = await upgradeNotice(process.env);
121
+ if (notice)
122
+ console.error(notice);
123
+ }
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CliError } from "./client.js";
4
+ import { stateDir } from "./config.js";
5
+ export const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
6
+ export const currentVersion = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
7
+ export async function latestVersion(env, timeoutMs) {
8
+ const registry = registryOf(env);
9
+ try {
10
+ const res = await fetch(`${registry}/@paxnax/berliner/latest`, { signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined });
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
+ // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
23
+ export const newer = (a, b) => {
24
+ const [x, y] = [a, b].map((v) => v.split("-")[0].split(".").map(Number));
25
+ const i = x.findIndex((n, j) => n !== y[j]);
26
+ return i >= 0 && x[i] > y[i];
27
+ };
28
+ const DAY_MS = 24 * 3600_000;
29
+ // Short, as it runs inline: the first command or session start of the day waits for it.
30
+ const CHECK_TIMEOUT_MS = 1500;
31
+ // Once a day per machine: the check file's mtime is when the last check started. A failed check counts too, so an
32
+ // offline machine doesn't wait on every run. Never throws; null unless the registry has a newer release.
33
+ export async function upgradeNotice(env) {
34
+ const stamp = join(stateDir(env), "version-check");
35
+ try {
36
+ if (Date.now() - statSync(stamp).mtimeMs < DAY_MS)
37
+ return null;
38
+ }
39
+ catch {
40
+ // Never checked on this machine.
41
+ }
42
+ try {
43
+ // Before the lookup, so runs started meanwhile don't check again.
44
+ mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
45
+ writeFileSync(stamp, "");
46
+ const latest = await latestVersion(env, CHECK_TIMEOUT_MS);
47
+ if (!newer(latest, currentVersion()))
48
+ return null;
49
+ return `New berliner CLI version ${latest} is out, run berliner upgrade to update it. If you are an AI agent, bring this to the user for approval instead of taking action yourself.`;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "keywords": [
6
6
  "claude-code",
package/skill/SKILL.md CHANGED
@@ -171,6 +171,23 @@ After the evidence line, it prints one line per file: `![Login page after the fi
171
171
 
172
172
  If the `berliner` command is not found, carry on with your work and say in your final message that `npx @paxnax/berliner@latest setup` needs to run. Do not try to install it yourself.
173
173
 
174
+ ## When a newer berliner is out
175
+
176
+ Once a day the CLI checks for a newer release. When there is one, the session-start reminder or a `berliner` command's output says: `New berliner CLI version X.Y.Z is out, run berliner upgrade to update it. If you are an AI agent, bring this to the user for approval instead of taking action yourself.` Never upgrade on your own:
177
+
178
+ 1. Run `berliner upgrade` with no flags. Without a terminal it prints its plan and stops, changing nothing.
179
+ 2. Show the user the plan, then ask them to approve each part separately: the berliner command, the skill and the hooks.
180
+ 3. If they decline the command, run nothing. Otherwise run the command that matches their answers. It never stops to ask:
181
+
182
+ | Skill | Hooks | Command |
183
+ |---|---|---|
184
+ | yes | yes | `berliner upgrade --yes` |
185
+ | no | yes | `berliner upgrade --yes --no-skill` |
186
+ | yes | no | `berliner upgrade --yes --no-hooks` |
187
+ | no | no | `berliner upgrade --yes --no-skill --no-hooks` |
188
+
189
+ 4. Pass on the upgrade's last line. When it names tools to start a new session in, tell the user the updated skill or hooks load only in a new session there.
190
+
174
191
  ## Timeouts
175
192
 
176
193
  The session is saved first, then evidence is uploaded. Allow at least 10 minutes for the command when attaching videos.