@bli-cockpit/cli 0.2.88 → 0.2.90

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.
@@ -68,6 +68,9 @@ export function parseOpsArgs(args) {
68
68
  "--memory-days",
69
69
  // BLI-3798: the coverage half on its own, with the bucket table.
70
70
  "--coverage",
71
+ // BLI-3912: one line per model — latency, tokens/s, today's spend, last
72
+ // probe verdict. A SECOND door like --memory, asked for only when named.
73
+ "--models",
71
74
  "--person",
72
75
  "--dry-run",
73
76
  "--json",
@@ -108,6 +111,7 @@ export function parseOpsArgs(args) {
108
111
  coverage: coverageOnly,
109
112
  skips: values.booleans.has("--skips"),
110
113
  memory: values.booleans.has("--memory") || memoryDays !== undefined,
114
+ models: values.booleans.has("--models"),
111
115
  ...(memoryDays === undefined ? {} : { memoryDays: Number(memoryDays) }),
112
116
  ...base,
113
117
  };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `cockpit models` — the model-card shelf, in the terminal (BLI-3912).
3
+ *
4
+ * cockpit models # every card: documented vs measured
5
+ * cockpit models list --json
6
+ * cockpit models show openai:gpt-5.6-terra
7
+ *
8
+ * Two nouns that look alike and are not: `cockpit model` is the ORG SETTING for
9
+ * which model a slot runs on (`model show`, `model set`); `cockpit models` is
10
+ * the shelf that setting picks FROM — windows, prices, reasoning vocabulary,
11
+ * quirks, probe verdicts and stale marks. The help text for each says so.
12
+ */
13
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
14
+ export function parseModelsArgs(args) {
15
+ const values = parseNamedArgs(args, {
16
+ allowedFlags: ["--home", "--dashboard-url", "--json"],
17
+ valueFlags: ["--home", "--dashboard-url"],
18
+ });
19
+ if (values.positionals.length > 2) {
20
+ throw new Error("models accepts at most an action (list|show) and one model id.");
21
+ }
22
+ const [rawAction, rawId] = values.positionals;
23
+ const base = {
24
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
25
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
26
+ json: values.booleans.has("--json"),
27
+ };
28
+ if (!rawAction || rawAction === "list") {
29
+ if (rawAction === "list" && rawId) {
30
+ throw new Error("models list takes no model id — use `models show <provider:model>`.");
31
+ }
32
+ return { kind: "models", action: "list", ...base };
33
+ }
34
+ if (rawAction !== "show") {
35
+ // A bare id is the shape people type first; treat it as `show <id>` rather
36
+ // than refusing on a technicality.
37
+ return { kind: "models", action: "show", modelId: rawAction, ...base };
38
+ }
39
+ const modelId = optionalNonEmpty(rawId);
40
+ if (!modelId) {
41
+ throw new Error("models show needs a model id, e.g. `models show openai:gpt-5.6-terra`.");
42
+ }
43
+ return { kind: "models", action: "show", modelId, ...base };
44
+ }
@@ -33,5 +33,6 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
33
33
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
34
34
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
35
35
  export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
36
+ export { parseModelsArgs } from "./local-args-tower-models.js";
36
37
  export { parseMailArgs } from "./local-args-tower-mail.js";
37
38
  export { parseCalArgs } from "./local-args-tower-cal.js";
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -63,6 +63,8 @@ export function parseLocalArgs(argv) {
63
63
  return parseJarvisArgs(argv.slice(1));
64
64
  case "model":
65
65
  return parseModelArgs(argv.slice(1));
66
+ case "models":
67
+ return parseModelsArgs(argv.slice(1));
66
68
  case "scout":
67
69
  return parseScoutArgs(argv.slice(1));
68
70
  case "ops":
@@ -273,6 +273,26 @@ export function localSubcommandHelp(command) {
273
273
  "Run `cockpit login` first if this machine is not paired.",
274
274
  ],
275
275
  ],
276
+ [
277
+ "models",
278
+ [
279
+ "Usage: cockpit models [list|show <provider:model>] [--dashboard-url <url>] [--json]",
280
+ "",
281
+ "The model-card shelf: one card per model, with what the PROVIDER documents (context",
282
+ "window, max output, price per million, reasoning modes, tool calling, structured output,",
283
+ "quirks) beside what OUR machine measured (latency p50/p95, observed tokens/s, the last",
284
+ "belt and conformance probe verdicts).",
285
+ "Every value carries the page it was read from; a field the provider does not state reads",
286
+ "unknown with the reason, never a guess.",
287
+ "STALE:<field> means a measurement contradicted the documentation — reasoning-off refused",
288
+ "where the card says it can be turned off, the belt refused where the card says tool",
289
+ "calling. The line under it is the command that re-reads the provider.",
290
+ "`cockpit models` is the SHELF; `cockpit model` is the org setting saying which model a",
291
+ "slot runs on. Different nouns, one letter apart, on purpose.",
292
+ "--json writes one object to stdout; operational lines stay on stderr.",
293
+ "Run `cockpit login` first if this machine is not paired.",
294
+ ],
295
+ ],
276
296
  [
277
297
  "scout",
278
298
  [
@@ -295,7 +315,7 @@ export function localSubcommandHelp(command) {
295
315
  [
296
316
  "ops",
297
317
  [
298
- "Usage: cockpit ops [status [--job <id>] [--coverage] [--skips] [--memory [--memory-days N]] | recompile --person <email|name|id> [--dry-run]] [--json]",
318
+ "Usage: cockpit ops [status [--job <id>] [--coverage] [--skips] [--memory [--memory-days N]] [--models] | recompile --person <email|name|id> [--dry-run]] [--json]",
299
319
  "",
300
320
  " cockpit ops status",
301
321
  " One line per scheduled job: when it last produced something, and whether that",
@@ -25,6 +25,7 @@ export const rootCommandNames = new Set([
25
25
  "analyze",
26
26
  "jarvis",
27
27
  "model",
28
+ "models",
28
29
  "scout",
29
30
  "ops",
30
31
  "slack",
@@ -70,8 +71,9 @@ export function localCommandHelp(command) {
70
71
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
71
72
  " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--date <YYYY-MM-DD>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--trace <id|last>] [--dashboard-url <url>] [--json [--show-approval-code]]",
72
73
  " cockpit model [show|set <provider:model>] [--json]",
74
+ " cockpit models [list|show <provider:model>] [--dashboard-url <url>] [--json]",
73
75
  " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
74
- " cockpit ops [status [--job <id>] [--skips] [--memory] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
76
+ " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
75
77
  " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [--person <p>] [--channel <c>] [--query <text>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>]] [--json]",
76
78
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
77
79
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes] | device [list] | device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]] [--json]",
@@ -16,6 +16,7 @@ import { runDoctor } from "./doctor.js";
16
16
  import { runStatus } from "./status.js";
17
17
  import { runSessions } from "./sessions.js";
18
18
  import { runJarvis } from "./jarvis.js";
19
+ import { runModels } from "./models.js";
19
20
  import { runScout } from "./scout.js";
20
21
  import { runOps } from "./ops.js";
21
22
  import { runSlack } from "./slack.js";
@@ -104,6 +105,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
104
105
  return await runJarvis(command, io);
105
106
  case "model":
106
107
  return await runModel(command, io);
108
+ case "models":
109
+ return await runModels(command, io);
107
110
  case "scout":
108
111
  return await runScout(command, io);
109
112
  case "ops":
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `cockpit models` — what do we know about each model? (BLI-3912)
3
+ *
4
+ * Prints the shelf: one line per model with the provider's documented window,
5
+ * price, reasoning mode and tool support beside OUR measured latency, observed
6
+ * tokens/s and last probe verdict, and a `STALE:<field>` mark wherever the two
7
+ * disagree — with the command that fixes it on the next line.
8
+ *
9
+ * `models show <id>` asks the same door for the SIX SECTIONS (BLI-3918) —
10
+ * what's going on, what's wrong, what's right, what should we do, double down,
11
+ * forget about it — so a terminal reads the model page a browser reads, in the
12
+ * same order, including today's spend and the `stale:<field>` markers.
13
+ *
14
+ * **Every line comes from the server** (`/api/models/cards`, rendered by
15
+ * `lib/models/model-lines.ts` and `card-section-lines.ts`). This file decides an
16
+ * exit code and nothing else. A terminal that formatted a card itself would
17
+ * eventually disagree with the board about what "stale" means, and the one that
18
+ * is wrong is always the one somebody is reading. A server that has not shipped
19
+ * the sections yet sends no `section_lines`, and the whole-card `lines` are
20
+ * printed instead — never an empty screen.
21
+ *
22
+ * **Exit codes.** Zero when the shelf was read, whatever it says — a stale card
23
+ * is a fact to act on, not a broken command. One when the read failed or when a
24
+ * named card does not exist, because a script asking about a specific model
25
+ * needs to know it asked about nothing.
26
+ */
27
+ import { writeLine } from "./cli-io.js";
28
+ import { loadPairedSession, towerJsonRequest, } from "../tower-client.js";
29
+ /** A card read is checked-in data on the server side; it should never hang a shell. */
30
+ const READ_DEADLINE_MS = 30_000;
31
+ export async function runModels(command, io) {
32
+ const session = await loadPairedSession("models", command.homeDir);
33
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
34
+ const log = (line) => writeLine(io.stderr, line);
35
+ const path = command.action === "show" && command.modelId
36
+ ? `/api/models/cards?id=${encodeURIComponent(command.modelId)}&sections=1`
37
+ : "/api/models/cards";
38
+ const result = await towerJsonRequest({
39
+ dashboardUrl,
40
+ path,
41
+ method: "GET",
42
+ deviceToken: session.device_token,
43
+ fetch: io.fetch,
44
+ label: "models",
45
+ timeoutMs: READ_DEADLINE_MS,
46
+ log,
47
+ });
48
+ if (!result.ok) {
49
+ writeFailure(command, io, result);
50
+ return 1;
51
+ }
52
+ const payload = (result.body ?? {});
53
+ if (command.json) {
54
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...payload }));
55
+ }
56
+ else {
57
+ const lines = payload.section_lines?.length ? payload.section_lines : (payload.lines ?? []);
58
+ for (const line of lines)
59
+ writeLine(io.stdout, line);
60
+ }
61
+ // Never silent about a card the server could not read: a shelf quietly one
62
+ // model short is exactly what the card registry exists to prevent.
63
+ for (const unreadable of payload.unreadable ?? []) {
64
+ writeLine(io.stderr, `[models cli] card unreadable ${JSON.stringify({ card: unreadable })}`);
65
+ }
66
+ writeLine(io.stderr, `[models cli] read ${JSON.stringify({
67
+ action: command.action,
68
+ model: command.modelId ?? null,
69
+ cards: Array.isArray(payload.cards) ? payload.cards.length : payload.card ? 1 : 0,
70
+ sections: payload.section_lines?.length ? "server" : "absent",
71
+ unreadable: payload.unreadable?.length ?? 0,
72
+ })}`);
73
+ return 0;
74
+ }
75
+ function writeFailure(command, io, failure) {
76
+ // `TowerFailure` carries the server's own words in `detail` — a 404 for a
77
+ // model nobody has carded says exactly which id it was and what to run.
78
+ const message = failure.detail;
79
+ if (command.json) {
80
+ writeLine(io.stdout, JSON.stringify({
81
+ ok: false,
82
+ error: failure.reason,
83
+ message,
84
+ httpStatus: failure.httpStatus ?? null,
85
+ }));
86
+ }
87
+ else {
88
+ writeLine(io.stdout, message);
89
+ }
90
+ writeLine(io.stderr, `[models cli] not read ${JSON.stringify({
91
+ reason: failure.reason,
92
+ http_status: failure.httpStatus ?? null,
93
+ model: command.modelId ?? null,
94
+ })}`);
95
+ }
@@ -77,6 +77,11 @@ async function runOpsStatus(command, io, tower) {
77
77
  // status board's exit code — "is the org using memory" going unread is not a
78
78
  // pipeline outage.
79
79
  const memory = command.memory ? await readMemoryUsage(command, io, tower) : null;
80
+ // BLI-3912. A THIRD door, asked for only when `--models` was, and never able
81
+ // to fail the board: "what is each model costing and how fast is it" is a
82
+ // different question from "is the pipeline healthy", and a card shelf that
83
+ // could not be read is not a collection outage.
84
+ const models = command.models ? await readModelBoardLines(io, tower) : null;
80
85
  // BLI-3723: `failing` joins the list. An older CLI that has never heard of it
81
86
  // simply does not count it — which is why the server keeps the sentence in
82
87
  // `detail`, so an un-upgraded terminal still PRINTS the outage even when it
@@ -88,7 +93,13 @@ async function runOpsStatus(command, io, tower) {
88
93
  // BLI-3762: a job that ran and wrote less than it owed is not healthy.
89
94
  row.verdict === "degraded");
90
95
  if (command.json) {
91
- writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section, hooks: memory.hooks, experience: memory.experience } : payload));
96
+ writeLine(io.stdout, JSON.stringify({
97
+ ...payload,
98
+ ...(memory
99
+ ? { memory: memory.section, hooks: memory.hooks, experience: memory.experience }
100
+ : {}),
101
+ ...(models ? { models: models.board, modelsReason: models.reason } : {}),
102
+ }));
92
103
  }
93
104
  else {
94
105
  const styled = colorEnabled(io);
@@ -116,6 +127,17 @@ async function runOpsStatus(command, io, tower) {
116
127
  for (const line of memory.experience.lines)
117
128
  writeLine(io.stdout, ` ${line}`);
118
129
  }
130
+ if (models) {
131
+ writeLine(io.stdout, "");
132
+ if (models.lines.length > 0) {
133
+ for (const line of models.lines)
134
+ writeLine(io.stdout, line);
135
+ }
136
+ else {
137
+ // Never a silent gap where a section was asked for.
138
+ writeLine(io.stdout, `MODELS not read (${models.reason})`);
139
+ }
140
+ }
119
141
  if (memory && !memory.section) {
120
142
  // Never a silent gap where a section was asked for: the reason the gauge
121
143
  // could not be read is printed where the gauge would have been.
@@ -160,6 +182,11 @@ async function runOpsStatus(command, io, tower) {
160
182
  // names one — a rollout fact, not a count of zero.
161
183
  memory_hook_via_daemon_24h: memory?.hooks?.via?.daemon ?? null,
162
184
  memory_hook_via_direct_24h: memory?.hooks?.via?.direct ?? null,
185
+ // BLI-3912: whether the model board was asked for, and what it said.
186
+ models_asked: Boolean(command.models),
187
+ models_reason: models?.reason ?? null,
188
+ models_counted: models?.board?.rows?.length ?? null,
189
+ models_cost_today_usd: models?.board?.total_cost_today_usd ?? null,
163
190
  })}`);
164
191
  return unhealthy.length > 0 ? 1 : 0;
165
192
  }
@@ -195,6 +222,29 @@ async function readMemoryUsage(command, io, tower) {
195
222
  return { section: null, hooks: body.hooks ?? null, experience: body.experience ?? null, reason: "memory_section_absent" };
196
223
  return { section: body.memory, hooks: body.hooks ?? null, experience: body.experience ?? null, reason: "ok" };
197
224
  }
225
+ /**
226
+ * The per-model board, read through its own door (BLI-3912).
227
+ *
228
+ * Same posture as the adoption gauge next door: it never fails the status
229
+ * board, and when it cannot be read the reason is printed where the section
230
+ * would have been. The LINES come from the server, so a terminal and the
231
+ * dashboard cannot disagree about what a model cost today.
232
+ */
233
+ async function readModelBoardLines(io, tower) {
234
+ const result = await callTower(tower, { path: "/api/ops/models", label: "ops-models" });
235
+ if (!result.ok) {
236
+ writeLine(io.stderr, `[ops cli] model board not read ${JSON.stringify({
237
+ reason: result.reason,
238
+ http_status: result.httpStatus ?? null,
239
+ })}`);
240
+ return { lines: [], board: null, reason: result.reason };
241
+ }
242
+ const body = asRecord(result.body);
243
+ if (!body.lines || body.lines.length === 0) {
244
+ return { lines: [], board: body.board ?? null, reason: "models_section_absent" };
245
+ }
246
+ return { lines: body.lines, board: body.board ?? null, reason: "ok" };
247
+ }
198
248
  async function runOpsRecompile(command, io, tower) {
199
249
  const person = command.person ?? "";
200
250
  if (!command.json) {
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.88");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.90");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.88",
3
+ "version": "0.2.90",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.20",
31
- "@bli-cockpit/mcp": "0.1.21",
31
+ "@bli-cockpit/mcp": "0.1.23",
32
32
  "@bli-cockpit/telemetry-core": "0.1.37"
33
33
  }
34
34
  }