@paxnax/berliner 0.7.0 → 0.9.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.
@@ -36,26 +36,26 @@ Setup saves the token and, when it isn't the default, the server address in `${X
36
36
  |---|---|---|
37
37
  | `BERLINER_TOKEN` | only without setup | An API token, created on the Settings page at https://berliner.app. Without it, the CLI uses the token in the config file. |
38
38
  | `BERLINER_URL` | no | The server to report to. Without it, the CLI uses the config file's `url`, then `https://berliner.app`; set it for a local or self-hosted server. |
39
- | `BERLINER_PLAN` | no | The plan the sessions from this shell run on. It replaces whatever `--plan` the agent passes, and the hooks use it too, so set it when one machine runs several accounts. Empty means unset. See [Several subscriptions](#several-subscriptions). |
39
+ | `BERLINER_VENDOR_SUBSCRIPTION_SLUG` | no | The slug of the subscription the sessions from this shell run on, from Settings → "Third-party vendor subscriptions". A session is put on it when it is created, by `berliner report`, `berliner update` or the hooks, and its plan replaces whatever `--plan` the agent passes, so set it when one machine runs several accounts. A session that already exists never moves to it. Empty means unset. See [Several subscriptions](#several-subscriptions). |
40
40
 
41
41
  ## Several subscriptions
42
42
 
43
- Berliner compares each subscription only with the sessions that ran on it. A session belongs to a subscription when both match:
43
+ Berliner compares each subscription only with the sessions that ran on it. Each subscription in Settings → "Third-party vendor subscriptions" has a slug, a short name of your choosing such as `work`. Leave it blank and it is `<vendor>-<plan>`, like `anthropic-max_x20` or `openai-pro`. A session belongs to a subscription:
44
44
 
45
- - **The vendor**, which comes from the model: `claude-…` is Claude, and `gpt-…`, `codex-…` or `o3`-style names are OpenAI.
46
- - **The plan**: your `BERLINER_PLAN` when it is set, otherwise the agent's `--plan`. When there is neither (the hooks never pass `--plan`, and reports inside Claude Code, Codex or OpenCode can leave it out), Berliner gives a new session the plan of your one subscription for its vendor. It is `api` when you list no subscription for that vendor, more than one, or the model is from neither vendor.
45
+ - **When the shell names it** with `BERLINER_VENDOR_SUBSCRIPTION_SLUG` as the session is created, or a report or update passes `--subscription <slug>`, which wins over the variable. The session takes that subscription's plan, whatever `--plan` the agent passed. A session that already exists stays on its subscription unless `--subscription` moves it. The subscription must be for the model's vendor: `claude-…` is Claude, and `gpt-…`, `codex-…` or `o3`-style names are OpenAI.
46
+ - **Otherwise by its plan**: the agent's `--plan`, or, when there is none (the hooks never pass `--plan`, and reports inside Claude Code, Codex or OpenCode can leave it out), the plan of your one subscription for the model's vendor. The session belongs to your one subscription for that vendor and plan, if there is exactly one. The plan is `api` when you list no subscription for that vendor, more than one, or the model is from neither vendor.
47
47
 
48
- An agent usually can't tell which account it's signed in to, so its `--plan` is a guess. If you run a work and a personal profile on one machine, give each one its plan with `BERLINER_PLAN`, one alias per profile. The hooks run inside the tool, so they see it too:
48
+ An agent usually can't tell which account it's signed in to, so its `--plan` is a guess. If you run a work and a personal profile on one machine, name each one's subscription with `BERLINER_VENDOR_SUBSCRIPTION_SLUG`, one alias per profile. The hooks run inside the tool, so they see it too:
49
49
 
50
50
  ```sh
51
- alias claude='BERLINER_PLAN=enterprise command claude'
52
- alias pclaude='CLAUDE_CONFIG_DIR=$HOME/.claude-personal BERLINER_PLAN=max_x20 command claude'
53
- alias codex='BERLINER_PLAN=pro command codex'
51
+ alias claude='BERLINER_VENDOR_SUBSCRIPTION_SLUG=work command claude'
52
+ alias pclaude='CLAUDE_CONFIG_DIR=$HOME/.claude-personal BERLINER_VENDOR_SUBSCRIPTION_SLUG=personal command claude'
53
+ alias codex='BERLINER_VENDOR_SUBSCRIPTION_SLUG=openai-pro command codex'
54
54
  ```
55
55
 
56
- Write `command claude`, not `claude`, inside each alias. Without `command`, `pclaude` expands the `claude` alias too, and its `BERLINER_PLAN=enterprise` wins.
56
+ Write `command claude`, not `claude`, inside each alias. Without `command`, `pclaude` expands the `claude` alias too, and its `BERLINER_VENDOR_SUBSCRIPTION_SLUG=work` wins. Two subscriptions on the same plan work too, as long as their slugs differ. If you rename a slug in Settings, update the aliases that use it.
57
57
 
58
- When `BERLINER_PLAN` is set and the agent passes a different `--plan`, Berliner uses yours and prints a warning. If `BERLINER_PLAN` isn't a valid plan, the session is saved with the agent's `--plan` instead, with a warning. With no `--plan`, as in the hooks, the report is sent without a plan instead, so a new session gets the server's pick and an existing one keeps its plan: `berliner report` prints a warning, and the hook writes one line to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`. Either way a typo never loses a session.
58
+ When the variable is set and the agent passes a different `--plan`, Berliner uses the subscription's plan and prints a warning. If the variable's slug isn't one of your subscriptions, or is for the other vendor, the session is saved with the agent's `--plan` instead, with a warning. With no `--plan`, as in the hooks, the report is sent without a plan instead, so a new session gets the server's pick and an existing one keeps its plan: `berliner report` prints a warning, and the hook writes one line to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`. Either way a typo never loses a session. A bad `--subscription` is different: you typed it, so the report fails with the server's message and nothing is saved.
59
59
 
60
60
  The plan values:
61
61
 
@@ -77,10 +77,16 @@ The plan values:
77
77
  | OpenAI | Business (premium seat) | `business_premium` |
78
78
  | either | Billed per token | `api` |
79
79
 
80
- Sessions reported before you set `BERLINER_PLAN` keep the plan the agent guessed. To fix one by hand from a shell where the variable is set, clear it for that command:
80
+ Sessions reported before you set the variable keep the plan the agent guessed. To move one to a subscription by hand:
81
81
 
82
82
  ```sh
83
- BERLINER_PLAN= berliner update 3f1c2a9e-7b64-4d0a-9a51-2c8f0e6d1b77 --plan enterprise
83
+ berliner update 3f1c2a9e-7b64-4d0a-9a51-2c8f0e6d1b77 --subscription work
84
+ ```
85
+
86
+ While the variable is set, `report` and `update` leave `--plan` out when they change a session that already exists, with a warning, so a guessed plan can't move it off your subscription. To set a plan by hand from such a shell, clear the variable for that command:
87
+
88
+ ```sh
89
+ BERLINER_VENDOR_SUBSCRIPTION_SLUG= berliner update 3f1c2a9e-7b64-4d0a-9a51-2c8f0e6d1b77 --plan pro
84
90
  ```
85
91
 
86
92
  ## Commands
@@ -102,26 +108,26 @@ berliner report \
102
108
  --evidence "./shots/after.png::Login page after the fix"
103
109
  ```
104
110
 
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.
111
+ Reporting again with the same `--session-id` replaces the values, adds PR URLs and skips evidence already uploaded. `--plan` is required unless you pass `--subscription <slug>` or `BERLINER_VENDOR_SUBSCRIPTION_SLUG` is set.
106
112
 
107
113
  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
114
 
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.
115
+ 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_VENDOR_SUBSCRIPTION_SLUG` 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.
110
116
 
111
117
  Other commands:
112
118
 
113
119
  - `berliner setup [--yes] [--no-install] [--no-browser]`: connect this terminal and install the CLI, the skill and the hooks, as in [Quickstart](#quickstart).
114
- - `berliner update <session-id> [flags]`: change fields of a session without resending everything. The plan changes only when you pass `--plan`. Inside the three tools, leave out the session id.
120
+ - `berliner update <session-id> [flags]`: change fields of a session without resending everything. The plan changes only when you pass `--plan` (left out while `BERLINER_VENDOR_SUBSCRIPTION_SLUG` is set, see [Several subscriptions](#several-subscriptions)), or `--subscription <slug>`, which moves the session to that subscription and its plan; `BERLINER_VENDOR_SUBSCRIPTION_SLUG` only applies when update creates the session, so an old session never moves to the shell's subscription. Inside the three tools, leave out the session id.
115
121
  - `berliner sessions [filters]`: list sessions. Filters: `--repo --model --project --ci --merge --since <N>d` or `--from --to`, plus `--limit` and `--json`.
116
122
  - `berliner session <session-id>`: show one session.
117
- - `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription.
123
+ - `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription, labelled with its slug: `Claude Max x20 (work)`.
118
124
  - `berliner projects [--json]`: the project names in use, then each repository's default project.
119
125
  - `berliner project [<name>] [--none] [--repo owner/name] [--json]`: with no name, show this repository's default project; with a name, save it; `--none` saves that the repository has no project. The repository comes from `git remote get-url origin` unless you pass `--repo`. A session's project is its own `--project`, else its repository's default, else its ticket's prefix (`SMTNC-3268` gives `SMTNC`). Until a repository has a default, `report` and `update` print a `Project: none saved` line, which asks your agent to suggest one to you.
120
126
  - `berliner team [<uuid>] [--none]`: with no argument, print the team your sessions report to and where it came from; with a uuid, save it for this machine; `--none` clears it. A session reports to a team, or to your own plan, from the first of these that is set: `--team <uuid>` or `--no-team` on `report` and `update`, `team` in `.berliner.json` at the repository root (`{ "team": "<uuid>" }`, meant to be committed so the whole team shares it), `BERLINER_TEAM`, then the saved setting. `--no-team` beats all of them. Only the report that creates a session sends the team; it stays fixed after that, and a later setting that differs prints a warning. The server refuses a team report with `team_not_found`, `team_not_member`, `team_not_eligible` (the repository's owner is not an organization the team covers) or `team_inactive` (the team has no active plan), and the message says how to fix it. Inside Claude Code, Codex or OpenCode the hook cannot print, so the next `berliner report`, `update`, `session` or `sessions` in that repository prints it once. The team's owner sees every session and file reported to the team; members never see each other's.
121
127
  - `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
122
128
  - `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
129
  - `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.
130
+ - `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
131
  - `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
132
 
127
133
  `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
@@ -9,6 +9,7 @@ const config = {
9
9
  "cost-status": { type: "string" },
10
10
  model: { type: "string" },
11
11
  plan: { type: "string" },
12
+ subscription: { type: "string" },
12
13
  task: { type: "string" },
13
14
  "task-url": { type: "string" },
14
15
  project: { type: "string" },
@@ -28,6 +29,7 @@ const config = {
28
29
  "no-install": { type: "boolean" },
29
30
  "no-browser": { type: "boolean" },
30
31
  "no-skill": { type: "boolean" },
32
+ "no-hooks": { type: "boolean" },
31
33
  none: { type: "boolean" },
32
34
  all: { type: "boolean" },
33
35
  run: { type: "string" },
@@ -21,6 +21,7 @@ export function sessionBody(values) {
21
21
  cost_status: values["cost-status"],
22
22
  model: values.model,
23
23
  plan: values.plan,
24
+ subscription: values.subscription,
24
25
  task_id: values.task,
25
26
  task_url: values["task-url"],
26
27
  project: values.project,
@@ -58,11 +59,11 @@ export async function fromTool(tool, values, env, command) {
58
59
  return filled;
59
60
  }
60
61
  // Inside a tool: the same fields, minus a derived title, for a session that exists; on a 404 (the first turn, before the hook
61
- // has reported), the PUT that creates it, at cost 0 while the cost is unknown and on BERLINER_PLAN when no plan is given.
62
- // Create-only: when another report created the session since the 404 (412), the PATCH after all, so its title and cost stay.
63
- export async function patchFirst(cfg, path, body, titled, guess, team = null) {
62
+ // has reported), the PUT that creates it, at cost 0 while the cost is unknown. Create-only: when another report created the
63
+ // session since the 404 (412), the PATCH after all, so its title and cost stay.
64
+ export async function patchFirst(cfg, path, body, titled, team = null) {
64
65
  const patch = async () => {
65
- const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
66
+ const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined });
66
67
  return { ...res, json: { created: false, ...res.json } };
67
68
  };
68
69
  try {
@@ -71,8 +72,8 @@ export async function patchFirst(cfg, path, body, titled, guess, team = null) {
71
72
  catch (err) {
72
73
  if (!(err instanceof CliError && err.status === 404))
73
74
  throw err;
74
- const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined, team: team ?? undefined };
75
- return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
75
+ const create = { ...body, cost_usd: body.cost_usd ?? 0, team: team ?? undefined };
76
+ return saveSession(cfg, "PUT", path, create, { "If-None-Match": "*" }).catch((err) => {
76
77
  if (err instanceof CliError && err.status === 412)
77
78
  return patch();
78
79
  throw err;
@@ -141,20 +142,27 @@ function prefilterPrs(body) {
141
142
  return { body: { ...body, prs: kept.length ? kept : undefined }, dropped };
142
143
  }
143
144
  // R46/R49: one invalid optional value must not lose the session. Bad PRs are filtered first; on a 422 naming
144
- // only optional fields, drop them and retry once. A plan from BERLINER_PLAN that the server rejects falls back to
145
- // `guess`, the agent's --plan, in that same retry; without --plan it is left out, so a new session gets the server's pick.
146
- // Anything else is thrown as before. Warnings are printed only once the save succeeded, so a failed save never claims
147
- // fields were dropped.
148
- export async function saveSession(cfg, method, path, input, guess, headers) {
145
+ // only optional fields, drop them and retry once. Anything else is thrown as before. Warnings are printed only once the
146
+ // save succeeded, so a failed save never claims fields were dropped.
147
+ // A PUT (the report that creates a session) without --subscription is on BERLINER_VENDOR_SUBSCRIPTION_SLUG; a PATCH never
148
+ // is, so an existing session never moves to this shell's subscription. Nor does such a PATCH send --plan: the server would
149
+ // re-link the session by plan, off the subscription the variable gave it. A slug from the variable that the server rejects
150
+ // is left out of the retry, which keeps the agent's --plan; without one, a new session gets the server's pick. An explicit
151
+ // --subscription is never dropped.
152
+ export async function saveSession(cfg, method, path, body, headers) {
153
+ const aliased = body.subscription === undefined && cfg.subscription !== null;
154
+ const fromEnv = aliased && method === "PUT";
155
+ const unplanned = aliased && method === "PATCH" && body.plan !== undefined;
156
+ const input = fromEnv ? { ...body, subscription: cfg.subscription ?? undefined } : unplanned ? { ...body, plan: undefined } : body;
149
157
  const first = prefilterPrs(input);
150
158
  const dropped = first.dropped;
151
- const fromEnv = cfg.plan !== null && input.plan === cfg.plan && guess !== cfg.plan;
152
- let fellBack = false;
159
+ let rejected;
153
160
  const finish = (res) => {
154
- if (fellBack)
155
- warn(`BERLINER_PLAN "${cfg.plan}" is not a plan; ${guess === undefined ? "sent the report without a plan" : `used --plan ${guess}`}`);
156
- else if (fromEnv && guess !== undefined)
157
- warn(`--plan ${guess} ignored, BERLINER_PLAN is ${cfg.plan}`);
161
+ const guess = body.plan;
162
+ if (rejected !== undefined)
163
+ warn(`BERLINER_VENDOR_SUBSCRIPTION_SLUG "${cfg.subscription}" isn't used: ${rejected}; ${guess === undefined ? "sent the report without a plan" : `used --plan ${guess}`}`);
164
+ else if (unplanned || (fromEnv && guess !== undefined && res.json.session.plan !== guess))
165
+ warn(`--plan ${guess} ignored, BERLINER_VENDOR_SUBSCRIPTION_SLUG is ${cfg.subscription}`);
158
166
  for (const d of dropped)
159
167
  warn(`${d.field}: ${d.reason}`);
160
168
  return { ...res, dropped };
@@ -164,11 +172,14 @@ export async function saveSession(cfg, method, path, input, guess, headers) {
164
172
  }
165
173
  catch (err) {
166
174
  const fieldErrors = err instanceof CliError && err.status === 422 ? err.details?.fieldErrors : undefined;
167
- const names = Object.keys(fieldErrors ?? {}).filter((n) => !(n === "plan" && fromEnv));
168
- fellBack = fromEnv && fieldErrors?.plan !== undefined;
169
- if (!fieldErrors || !(names.length || fellBack) || !names.every((n) => optional.includes(n)))
175
+ const names = Object.keys(fieldErrors ?? {}).filter((n) => !(n === "subscription" && fromEnv));
176
+ if (fromEnv && fieldErrors?.subscription !== undefined)
177
+ rejected = [fieldErrors.subscription].flat().join(", ");
178
+ if (!fieldErrors || !(names.length || rejected !== undefined) || !names.every((n) => optional.includes(n)))
170
179
  throw err;
171
- const retry = { ...first.body, ...(fellBack ? { plan: guess } : {}) };
180
+ const retry = { ...first.body };
181
+ if (rejected !== undefined)
182
+ delete retry.subscription;
172
183
  for (const field of names) {
173
184
  delete retry[field];
174
185
  dropped.push({ field, reason: field === "prs" ? `all PRs dropped: ${[fieldErrors[field]].flat().join(", ")}` : [fieldErrors[field]].flat().join(", ") });
@@ -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;
@@ -237,28 +240,32 @@ async function send(tool, file, env) {
237
240
  saved = await request(cfg, "PATCH", path, body);
238
241
  }
239
242
  catch (err) {
243
+ if (isRefusal(err))
244
+ noteRefusal(env, repository, err.message);
240
245
  if (!(err instanceof CliError && err.status === 404))
241
246
  throw err;
242
- // The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
243
- // session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
244
- // only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
245
- const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan, team }, { headers: { "If-None-Match": "*" } }).catch((err) => {
247
+ // The plan is that of BERLINER_VENDOR_SUBSCRIPTION_SLUG from the tool's environment, else the server's pick from the
248
+ // user's subscriptions. A new session needs a cost: 0 until prices load. Create-only: when an agent report created the
249
+ // session since the 404 (412), only the machine's fields are sent. A server that ignores the header overwrites the
250
+ // title, as before.
251
+ const create = (subscription) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), subscription, team }, { headers: { "If-None-Match": "*" } }).catch((err) => {
246
252
  if (err instanceof CliError && err.status === 412)
247
253
  return request(cfg, "PATCH", path, body);
248
254
  // Never a personal session instead: the next foreground command in this repository prints the refusal.
249
- if (err instanceof CliError && err.status === 422 && err.code?.startsWith("team_"))
255
+ if (isRefusal(err))
250
256
  noteRefusal(env, repository, err.message);
251
257
  throw err;
252
258
  });
253
259
  try {
254
- saved = await create(cfg.plan ?? undefined);
260
+ saved = await create(cfg.subscription ?? undefined);
255
261
  }
256
262
  catch (err) {
257
- // A BERLINER_PLAN the server rejects must not lose the session: the server picks the plan instead.
258
- if (!(cfg.plan && err instanceof CliError && err.status === 422 && err.details?.fieldErrors?.plan))
263
+ // A slug the server rejects must not lose the session: the server picks the plan instead.
264
+ const rejected = cfg.subscription && err instanceof CliError && err.status === 422 ? err.details?.fieldErrors?.subscription : undefined;
265
+ if (!rejected)
259
266
  throw err;
260
267
  saved = await create(undefined);
261
- log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
268
+ log(env, `${tool} ${sessionId}: BERLINER_VENDOR_SUBSCRIPTION_SLUG "${cfg.subscription}" isn't used: ${[rejected].flat().join(", ")}; the server picked the plan`);
262
269
  }
263
270
  }
264
271
  const mismatch = teamWarning(saved.json.session.team, team ?? null);
@@ -266,6 +273,8 @@ async function send(tool, file, env) {
266
273
  log(env, `${tool} ${sessionId}: ${mismatch}`);
267
274
  await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
268
275
  }
276
+ // A 422 the user must hear about: no plan, or a team the server refuses.
277
+ const isRefusal = (err) => err instanceof CliError && err.status === 422 && (err.code === "plan_required" || !!err.code?.startsWith("team_"));
269
278
  // Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
270
279
  // In parallel, 5 seconds each, inside the child's deadline. Only changes are sent.
271
280
  async function refreshPrs(cfg, path, prs, env, where) {
@@ -14,9 +14,9 @@ export async function report(cfg, values, positionals, env) {
14
14
  const titled = values.title !== undefined;
15
15
  if (tool)
16
16
  values = await fromTool(tool, values, env, "report");
17
- // BERLINER_PLAN stands in for --plan. Inside a tool, a report without a plan leaves it to the server, and one without a
18
- // cost has no prices to estimate it.
19
- const missing = required.filter((flag) => values[flag] === undefined && !(flag === "plan" && cfg.plan) && !(tool && (flag === "plan" || flag === "cost")));
17
+ // --subscription or BERLINER_VENDOR_SUBSCRIPTION_SLUG stands in for --plan. Inside a tool, a report without a plan leaves it to the server,
18
+ // and one without a cost has no prices to estimate it.
19
+ const missing = required.filter((flag) => values[flag] === undefined && !(flag === "plan" && (values.subscription || cfg.subscription)) && !(tool && (flag === "plan" || flag === "cost")));
20
20
  if (missing.length)
21
21
  throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
22
22
  if (values.public && !values.evidence?.length)
@@ -24,14 +24,14 @@ export async function report(cfg, values, positionals, env) {
24
24
  // Parsed before anything is saved, so a bad label saves nothing.
25
25
  const evidence = (values.evidence ?? []).map(parseEvidence);
26
26
  const team = await resolveTeam(values, process.cwd(), env);
27
- const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
27
+ const body = sessionBody(values);
28
28
  const id = values["session-id"];
29
29
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
30
30
  // Inside a tool a report without --title is usually a follow-up: the derived title is only for a new session. A report
31
31
  // without a cost must not replace the saved one either.
32
32
  const { json: saved, dropped } = await (tool && (!titled || body.cost_usd === undefined)
33
- ? patchFirst(cfg, path, body, titled, values.plan, team)
34
- : saveSession(cfg, "PUT", path, { ...body, team: team ?? undefined }, values.plan));
33
+ ? patchFirst(cfg, path, body, titled, team)
34
+ : saveSession(cfg, "PUT", path, { ...body, team: team ?? undefined }));
35
35
  const json = dropped.length ? { ...saved, dropped } : saved;
36
36
  const { created, session } = json;
37
37
  warnTeam(session, team);
@@ -19,7 +19,7 @@ export async function summary(cfg, values) {
19
19
  function subscriptionLines(c) {
20
20
  if (!c)
21
21
  return [];
22
- const lines = c.rows.map((r) => `${subscriptionLabel(r)}: ${totalLabel(r.spend_usd, r.unpriced_sessions ?? 0)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
22
+ const lines = c.rows.map((r) => `${subscriptionLabel(r)} (${r.slug}): ${totalLabel(r.spend_usd, r.unpriced_sessions ?? 0)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
23
23
  if (c.unmatched.sessions > 0)
24
24
  lines.push(`On no listed plan: ${totalLabel(c.unmatched.spend_usd, c.unmatched.unpriced_sessions ?? 0)}, ${c.unmatched.sessions} session(s)`);
25
25
  return lines;
@@ -18,11 +18,11 @@ export async function update(cfg, values, positionals, env) {
18
18
  if (tool)
19
19
  values = await fromTool(tool, { ...values, "session-id": positionals[0] ?? values["session-id"] }, env, "update");
20
20
  const id = tool ? tool.sessionId : positionals[0];
21
- // A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
22
- const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
21
+ // A plan is only sent when --plan or --subscription is passed, so an update never changes it unasked.
22
+ const body = sessionBody(values);
23
23
  const team = await resolveTeam(values, process.cwd(), env);
24
24
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
25
- const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan, team) : saveSession(cfg, "PATCH", path, body, values.plan));
25
+ const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, team) : saveSession(cfg, "PATCH", path, body));
26
26
  const json = dropped.length ? { ...saved, dropped } : saved;
27
27
  warnTeam(json.session, team);
28
28
  print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.${projectHint(json.session)}` }, values.json === true);
@@ -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/config.js CHANGED
@@ -44,5 +44,9 @@ export function saveConfig(env, value) {
44
44
  }
45
45
  export function readConfig(env) {
46
46
  const file = readConfigFile(env);
47
- return { url: env.BERLINER_URL || file?.url || DEFAULT_URL, token: env.BERLINER_TOKEN || file?.token || null, plan: env.BERLINER_PLAN || null };
47
+ return {
48
+ url: env.BERLINER_URL || file?.url || DEFAULT_URL,
49
+ token: env.BERLINER_TOKEN || file?.token || null,
50
+ subscription: env.BERLINER_VENDOR_SUBSCRIPTION_SLUG || null,
51
+ };
48
52
  }
package/dist/index.js CHANGED
@@ -19,15 +19,17 @@ 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:
25
26
  setup connect this terminal, install the CLI and skill [--yes --no-install --no-browser]
26
- report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p>
27
+ report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p> | --subscription <slug>
27
28
  [--task --task-url --project --started-at --ended-at --commits --coverage]
28
29
  [--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--public] [--json] [--strict]
29
30
  inside Claude Code, Codex or OpenCode the session id, cost, model and repo are filled in,
30
- and the server picks the plan (BERLINER_PLAN overrides it)
31
+ and the server picks the plan (BERLINER_VENDOR_SUBSCRIPTION_SLUG overrides it for a new session)
32
+ --subscription puts the session on that subscription and its plan, over the variable
31
33
  update <session-id> same flags as report, all optional (no session id inside those tools)
32
34
  sessions [--repo --model --project --ci --merge --since <N>d | --from --to] [--limit <n>] [--json]
33
35
  session <session-id> [--json]
@@ -43,11 +45,12 @@ Commands:
43
45
  delete <session-id> [--evidence <id>] [--yes]
44
46
  evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
45
47
  evidence private <id> make it viewable only by you again [--json]
46
- upgrade install the latest CLI and skill [--yes --no-skill]
48
+ upgrade install the latest CLI, skill and hooks [--yes --no-skill --no-hooks]
47
49
  skill print the bundled SKILL.md
48
50
  hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
49
51
 
50
- Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan), BERLINER_TEAM`;
52
+ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_VENDOR_SUBSCRIPTION_SLUG (the subscription of new sessions),
53
+ BERLINER_TEAM`;
51
54
  const args = parse(process.argv.slice(2));
52
55
  // Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
53
56
  const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
@@ -114,3 +117,9 @@ catch (err) {
114
117
  console.error(usage);
115
118
  process.exitCode = softFail ? 0 : 1;
116
119
  }
120
+ // The hook carries it in the session-start reminder instead, and upgrade looks for itself.
121
+ if (args.command !== "hook" && args.command !== "upgrade") {
122
+ const notice = await upgradeNotice(process.env);
123
+ if (notice)
124
+ console.error(notice);
125
+ }
@@ -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.9.0",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "keywords": [
6
6
  "claude-code",
package/skill/SKILL.md CHANGED
@@ -30,7 +30,7 @@ Hooks installed by `berliner setup` already report the session's cost, model, re
30
30
  | `--evidence` | Each screenshot or video. |
31
31
  | `--team`, `--no-team` | Only when the user asks for a one-off. See [Teams](#teams). |
32
32
 
33
- Do not pass `--session-id`, `--cost` or `--model`: the CLI fills them in from the tool's own records, and an explicit value is ignored with a warning. `--repo` and `--plan` are not needed either: the plan comes from the user's `BERLINER_PLAN`, or from their subscriptions in Settings. A report without `--title` keeps the session's existing title. `berliner update` ignores a session ID here.
33
+ Do not pass `--session-id`, `--cost` or `--model`: the CLI fills them in from the tool's own records, and an explicit value is ignored with a warning. `--repo` and `--plan` are not needed either: the plan comes from the user's `BERLINER_VENDOR_SUBSCRIPTION_SLUG`, or from their subscriptions in Settings. A report without `--title` keeps the session's existing title. `berliner update` ignores a session ID here.
34
34
 
35
35
  ## Name the session
36
36
 
@@ -137,7 +137,7 @@ Without a hook, `report` needs these flags:
137
137
  | `--repo` | `owner/name`, from `git remote get-url origin`. |
138
138
  | `--cost` | Estimated USD. Your harness's reported session cost if it shows one, otherwise estimate from token usage at the model's API prices. |
139
139
  | `--model` | The primary model ID you ran on. |
140
- | `--plan` | Your best guess at the plan you run on. Claude: `free`, `pro`, `max_x5`, `max_x20`, `team`, `enterprise`. OpenAI (Codex): `free`, `go`, `plus`, `pro`, `pro_more`, `pro_max`, `business`, `business_premium`. Billed per token: `api`. Always pass it: when the user has set `BERLINER_PLAN`, that value replaces yours. |
140
+ | `--plan` | Your best guess at the plan you run on. Claude: `free`, `pro`, `max_x5`, `max_x20`, `team`, `enterprise`. OpenAI (Codex): `free`, `go`, `plus`, `pro`, `pro_more`, `pro_max`, `business`, `business_premium`. Billed per token: `api`. Always pass it: when the user has set `BERLINER_VENDOR_SUBSCRIPTION_SLUG`, that subscription's plan replaces yours. |
141
141
 
142
142
  Report again with the same `--session-id` whenever something changes.
143
143
 
@@ -167,10 +167,27 @@ After the evidence line, it prints one line per file: `![Login page after the fi
167
167
 
168
168
  ## Environment
169
169
 
170
- `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`.
170
+ `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_VENDOR_SUBSCRIPTION_SLUG`, when the user sets it, names their subscription, and its plan replaces any `--plan`.
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.