@bli-cockpit/cli 0.2.165 → 0.2.167

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.
@@ -14,9 +14,14 @@
14
14
  * captured by this very product's own session harvester. That is the
15
15
  * sourcing-secrets verdict applied to the one credential this product
16
16
  * stores.
17
- * - **The message body.** `cockpit mail send` takes it from stdin or
18
- * `--file`, the same discipline `cockpit issue comment` and `cockpit docs
19
- * update` keep.
17
+ * - **The message body.** `cockpit mail send` and `cockpit mail draft` take it
18
+ * from stdin or `--file`, the same discipline `cockpit issue comment` and
19
+ * `cockpit docs update` keep.
20
+ *
21
+ * `draft` (BLI-12530) takes `send`'s flags exactly — `--account`, `--to`,
22
+ * `--cc`, `--subject`, `--reply-to`, `--file` — and is refused the same two
23
+ * ways when `--account` or `--to` is missing: which mailbox a message is saved
24
+ * in is as much the caller's to say as which one it goes out from.
20
25
  */
21
26
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
27
  const MAIL_ACTIONS = new Set([
@@ -27,10 +32,13 @@ const MAIL_ACTIONS = new Set([
27
32
  "read",
28
33
  "search",
29
34
  "send",
35
+ "draft",
30
36
  "attachment",
31
37
  "sync",
32
38
  "sync-status",
33
39
  ]);
40
+ /** The two verbs that put a message in a mailbox: one sends it, one saves it unsent. */
41
+ const MAIL_ACTIONS_COMPOSING = new Set(["send", "draft"]);
34
42
  /** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
35
43
  const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
36
44
  /** `sync-status [<account id>]` — every mailbox, or one's. Ticket 10730. */
@@ -85,7 +93,7 @@ function readMailAction(values) {
85
93
  const first = values.positionals[0];
86
94
  const action = (first === undefined ? "inbox" : first);
87
95
  if (!MAIL_ACTIONS.has(action)) {
88
- throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, sync, or sync-status.`);
96
+ throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, draft, attachment, sync, or sync-status.`);
89
97
  }
90
98
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
91
99
  }
@@ -127,11 +135,13 @@ function readImapAddress(action, values) {
127
135
  function readMailRecipients(action, values) {
128
136
  const to = splitAddresses(values.flags.get("--to"));
129
137
  const cc = splitAddresses(values.flags.get("--cc"));
130
- if (action === "send" && to.length === 0) {
131
- throw new Error('mail send needs --to "someone@example.com" (comma-separated for several).');
138
+ if (MAIL_ACTIONS_COMPOSING.has(action) && to.length === 0) {
139
+ throw new Error(`mail ${action} needs --to "someone@example.com" (comma-separated for several).`);
132
140
  }
133
- if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
134
- throw new Error("mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
141
+ if (MAIL_ACTIONS_COMPOSING.has(action) && !optionalNonEmpty(values.flags.get("--account"))) {
142
+ throw new Error(action === "draft"
143
+ ? "mail draft needs --account <id>: with several mailboxes attached, which one the draft is saved in is yours to say. `cockpit mail accounts` lists them."
144
+ : "mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
135
145
  }
136
146
  return { to, cc };
137
147
  }
@@ -122,7 +122,7 @@ export const TOWER_COMMAND_HELP = [
122
122
  [
123
123
  "mail",
124
124
  [
125
- "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
125
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|draft|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
126
126
  "",
127
127
  "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
128
128
  "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
@@ -134,12 +134,13 @@ export const TOWER_COMMAND_HELP = [
134
134
  "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
135
135
  "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
136
136
  "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
137
+ "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
137
138
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
138
139
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
139
140
  "sync-status [<account>] — every mailbox's health, or one's: last successful sync, the last pass's own reason, how many messages it carried, failures in the last 24h, and the lag between a message's own clock and when Tower stored it (ticket 10730).",
140
141
  "detach <account> — removes the mailbox, its stored mail and its credential.",
141
142
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. Shapes differ by verb: inbox/read answer { messages: [...] }, search answers { matches: [...] } — same MessageRow shape either name, always carrying thread_id.",
142
- "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
143
+ "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, account_not_yours, google_oauth_not_configured, credential_unavailable, and so on.",
143
144
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
144
145
  ],
145
146
  ],
@@ -196,6 +197,7 @@ export const TOWER_COMMAND_HELP = [
196
197
  "labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
197
198
  "--since takes <n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp; --until an ISO timestamp (default now).",
198
199
  "--detail adds Output, Input, Cache read and Cache creation. Counts use K/M/B; dollars are rounded.",
200
+ "PRs opened, PRs merged and Commits come from the pull requests Tower mirrors from GitHub, matched to people by GitHub login. n/a means Tower cannot match that person to a login: unknown, not zero. The footer says how many repositories the mirror holds, the range of its pull requests and what matched nobody; --json lists the repositories.",
199
201
  "--include-automated adds harness, subagent and scheduled sessions, which are excluded by default.",
200
202
  "It presses the same door as the /usage page and the usage_people MCP tool (GET /api/usage/people).",
201
203
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -124,7 +124,7 @@ export function localCommandHelp(command) {
124
124
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
125
125
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
126
126
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
127
- " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
127
+ " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
128
128
  " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
129
129
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
130
130
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The compose half of `cockpit mail` (BLI-12530): `draft`, and the two things
3
+ * it shares with `send` — where a message body comes from, and the JSON both
4
+ * verbs post.
5
+ *
6
+ * `cockpit mail draft` saves a message as a DRAFT in one of your own mailboxes
7
+ * (`POST /api/mail/drafts`) so a person can read it, edit it and press send
8
+ * themselves. It is the verb for an agent that prepares mail on somebody's
9
+ * behalf: nothing is sent, nobody is notified, and so — unlike `send` — there
10
+ * is nothing to confirm.
11
+ *
12
+ * Kept beside `mail.ts` rather than in it because `mail.ts` sits at the edge of
13
+ * the readability ceiling, and because the two verbs genuinely share one shape:
14
+ * the request body is built once here, so a field added to `send` cannot be
15
+ * forgotten by `draft` (both doors accept exactly the same fields).
16
+ *
17
+ * THE BODY NEVER TOUCHES ARGV. It comes from `--file` or stdin, the discipline
18
+ * `send`, `issue comment` and `docs update` already keep — argv is world-
19
+ * readable on a shared machine and is captured by this product's own session
20
+ * harvester.
21
+ */
22
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
23
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
24
+ import { readNoteFile } from "./notes-file.js";
25
+ // One spelling per surface: the same tag `mail.ts` uses.
26
+ const TAG = "[mail cli]";
27
+ const WRITE_DEADLINE_MS = 60_000;
28
+ const BODY_MAX_CHARS = 200_000;
29
+ /** Body on stdin, never argv — `--file` is the safest way on Windows. */
30
+ export async function readMailBody(command, door) {
31
+ if (command.filePath) {
32
+ const read = await readNoteFile(command.filePath);
33
+ if (!read.ok) {
34
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
35
+ }
36
+ return { ok: true, text: read.bytes.toString("utf8") };
37
+ }
38
+ if (isInteractiveStdin(door.io))
39
+ return { ok: true, text: "" };
40
+ try {
41
+ const text = await readPipedText(door.io.stdin, {
42
+ maxChars: BODY_MAX_CHARS,
43
+ overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
44
+ });
45
+ return { ok: true, text };
46
+ }
47
+ catch (error) {
48
+ return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
49
+ }
50
+ }
51
+ /**
52
+ * The JSON that `send` and `draft` post. One shape on purpose: the two doors
53
+ * take the same fields, and a message that can be sent can be saved unsent.
54
+ */
55
+ export function composedMessageBody(command, text) {
56
+ return {
57
+ account_id: command.accountId,
58
+ to: command.to.map((address) => ({ address })),
59
+ ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
60
+ subject: command.subjectLine ?? "",
61
+ text,
62
+ ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
63
+ };
64
+ }
65
+ /** `draft --account <id> --to <a[,b]> [--cc <c>] --subject "<s>" [--reply-to <message id>] [--file <path>]` */
66
+ export async function runMailDraft(command, door) {
67
+ const body = await readMailBody(command, door);
68
+ if (!body.ok)
69
+ return failAgentDoor(door, TAG, body.reason, body.detail);
70
+ if (body.text.trim() === "") {
71
+ return failAgentDoor(door, TAG, "body_required", 'A draft needs a body: `echo "Tuesday works for me." | cockpit mail draft --account <id> --to someone@example.com --subject "Re: lunch"`.');
72
+ }
73
+ const answer = await askAgentDoor(door, {
74
+ path: "/api/mail/drafts",
75
+ method: "POST",
76
+ label: "mail draft",
77
+ timeoutMs: WRITE_DEADLINE_MS,
78
+ body: composedMessageBody(command, body.text),
79
+ });
80
+ if (!answer.ok)
81
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
82
+ const draft = answer.body.draft;
83
+ if (door.json)
84
+ return emitAgentDoor(door, { ok: true, draft });
85
+ writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
86
+ + "Nothing was sent: it is in Drafts for a person to read, edit and send.");
87
+ if (command.replyTo) {
88
+ // `threaded` is what Gmail answered, not what was asked: a reply is only
89
+ // filed in its conversation when the subject still matches the original's.
90
+ writeLine(door.io.stdout, draft?.threaded
91
+ ? "It sits in the reply's conversation."
92
+ : "Gmail did not file it in that conversation, so it is a new one. A reply joins its thread only when the subject matches the original's.");
93
+ }
94
+ writeLine(door.io.stdout, `draft ${draft?.provider_draft_id ?? "(no id returned)"} thread ${draft?.provider_thread_id ?? "(none)"} account ${draft?.account_id ?? command.accountId}`);
95
+ return 0;
96
+ }
@@ -2,10 +2,12 @@
2
2
  * `cockpit mail` — the mailboxes a person attached, from a terminal
3
3
  * (BLI-3708).
4
4
  *
5
- * Nine verbs over `/api/mail/**`, the same doors the browser will use when the
5
+ * Ten verbs over `/api/mail/**`, the same doors the browser will use when the
6
6
  * UI lands. Every one of those routes already accepts the collector device
7
7
  * token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
8
8
  * terminal in front of an existing door and adds no authority of its own.
9
+ * `draft` (BLI-12530) lives in `mail-compose.ts` beside the body reader it
10
+ * shares with `send`.
9
11
  *
10
12
  * The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
11
13
  * king"): a mailbox is easier to reason about as `--json` than as a rendered
@@ -23,7 +25,7 @@
23
25
  import { writeFile } from "node:fs/promises";
24
26
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
- import { readNoteFile } from "./notes-file.js";
28
+ import { composedMessageBody, readMailBody, runMailDraft } from "./mail-compose.js";
27
29
  import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
28
30
  import { towerRequest } from "../tower-client.js";
29
31
  const TAG = "[mail cli]";
@@ -31,7 +33,6 @@ const READ_DEADLINE_MS = 30_000;
31
33
  const WRITE_DEADLINE_MS = 60_000;
32
34
  /** A sync pass reads a bounded page, but a cold mailbox can still take a while. */
33
35
  const SYNC_DEADLINE_MS = 120_000;
34
- const BODY_MAX_CHARS = 200_000;
35
36
  const PASSWORD_MAX_CHARS = 200;
36
37
  export async function runMail(command, io) {
37
38
  const door = await openAgentDoor("mail", command, io);
@@ -50,6 +51,8 @@ export async function runMail(command, io) {
50
51
  return search(command, door);
51
52
  case "send":
52
53
  return send(command, door);
54
+ case "draft":
55
+ return runMailDraft(command, door);
53
56
  case "attachment":
54
57
  return attachment(command, door);
55
58
  case "sync":
@@ -250,7 +253,7 @@ async function search(command, door) {
250
253
  return 0;
251
254
  }
252
255
  async function send(command, door) {
253
- const body = await readBody(command, door);
256
+ const body = await readMailBody(command, door);
254
257
  if (!body.ok)
255
258
  return failAgentDoor(door, TAG, body.reason, body.detail);
256
259
  if (body.text.trim() === "") {
@@ -261,14 +264,7 @@ async function send(command, door) {
261
264
  method: "POST",
262
265
  label: "mail send",
263
266
  timeoutMs: WRITE_DEADLINE_MS,
264
- body: {
265
- account_id: command.accountId,
266
- to: command.to.map((address) => ({ address })),
267
- ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
268
- subject: command.subjectLine ?? "",
269
- text: body.text,
270
- ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
271
- },
267
+ body: composedMessageBody(command, body.text),
272
268
  });
273
269
  if (!answer.ok)
274
270
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
@@ -361,26 +357,4 @@ async function syncStatus(command, door) {
361
357
  writeLine(door.io.stdout, `${account.address} last_success=${account.last_success_at ?? "never"} last_run=${lastRun} failures_24h=${account.failures_24h} lag=${lag}${account.data_incomplete ? " (partial read)" : ""}`);
362
358
  }
363
359
  return 0;
364
- }
365
- /** Body on stdin, never argv — `--file` is the safest way on Windows. */
366
- async function readBody(command, door) {
367
- if (command.filePath) {
368
- const read = await readNoteFile(command.filePath);
369
- if (!read.ok) {
370
- return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
371
- }
372
- return { ok: true, text: read.bytes.toString("utf8") };
373
- }
374
- if (isInteractiveStdin(door.io))
375
- return { ok: true, text: "" };
376
- try {
377
- const text = await readPipedText(door.io.stdin, {
378
- maxChars: BODY_MAX_CHARS,
379
- overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
380
- });
381
- return { ok: true, text };
382
- }
383
- catch (error) {
384
- return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
385
- }
386
360
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.165");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.167");
25
25
  return 0;
26
26
  }
27
27
 
@@ -17,4 +17,37 @@ export function formatUsageDollars(value) {
17
17
  return new Intl.NumberFormat("en-US", {
18
18
  style: "currency", currency: "USD", maximumFractionDigits: 0,
19
19
  }).format(value);
20
+ }
21
+ const noun = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
22
+ /** A row whose counts are numbers, or ride on the same person's other row. Anything else is a gap. */
23
+ const PLACED = new Set(["github_login", "counted_on_other_account"]);
24
+ /**
25
+ * The sentences under the table, worded exactly as the /usage page and the
26
+ * `usage_people` MCP tool word them (`apps/dashboard/src/lib/usage/
27
+ * usage-github-words.ts` is the original; each copy has the same cases).
28
+ * `people` is whoever the table shows, so `--person` narrows the totals too.
29
+ */
30
+ export function githubWords(github, people) {
31
+ const label = github.counts_label ?? "";
32
+ if (github.status === "unavailable") {
33
+ const why = github.detail ? ` (${github.detail})` : "";
34
+ return { lines: [`GitHub counts are unavailable: ${github.reason ?? "unknown_reason"}${why}.`], label };
35
+ }
36
+ const sum = (field) => people.reduce((total, person) => total + (person.github?.[field] ?? 0), 0);
37
+ const range = github.oldest_pull_request_at && github.newest_pull_request_at
38
+ ? `; mirrored pull requests run from ${github.oldest_pull_request_at} to ${github.newest_pull_request_at}`
39
+ : "; the mirror holds no pull requests";
40
+ const lines = [
41
+ `GitHub: ${noun(sum("pull_requests_opened"), "pull request")} opened, ${sum("pull_requests_merged")} merged, ${noun(sum("commits"), "commit")}, ` +
42
+ `from ${noun((github.repositories ?? []).length, "mirrored repository", "mirrored repositories")}${range}.`,
43
+ ];
44
+ const unmatched = github.unattributed;
45
+ if (unmatched && unmatched.pull_requests_opened + unmatched.pull_requests_merged + unmatched.commits > 0) {
46
+ lines.push(`Not matched to a person: ${noun(unmatched.pull_requests_opened, "pull request")} opened, ${unmatched.pull_requests_merged} merged, ${noun(unmatched.commits, "commit")}.`);
47
+ }
48
+ const unplaced = people.filter((person) => person.github && !PLACED.has(person.github.match)).length;
49
+ if (unplaced > 0) {
50
+ lines.push(`${noun(unplaced, "person cannot", "people cannot")} be matched to a GitHub login, so their GitHub counts are unknown, not zero.`);
51
+ }
52
+ return { lines, label };
20
53
  }
@@ -1,5 +1,5 @@
1
1
  import { loadPairedSession } from "../tower-client.js";
2
- import { formatCount, formatUsageDollars } from "./usage-format.js";
2
+ import { formatCount, formatUsageDollars, githubWords } from "./usage-format.js";
3
3
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
4
4
  import { writeLine } from "./cli-io.js";
5
5
  export async function runUsage(command, io) {
@@ -71,22 +71,30 @@ export async function runUsage(command, io) {
71
71
  for (const sub of subject.children ?? [])
72
72
  nameWidth = Math.max(nameWidth, sub.subtopic.length + 4);
73
73
  }
74
- const widths = [nameWidth, 8, 15, 12, 10, 10, 12, 14];
74
+ // BLI-12561: the three GitHub columns sit right after Coverage, ahead of the
75
+ // optional token splits, and only when the dashboard sent a `github` block.
76
+ // A sub-row has no GitHub counts of its own and prints blanks in their place,
77
+ // so its token splits stay under the right headings.
78
+ const hasGithub = body.github !== undefined;
79
+ const githubBlanks = hasGithub ? ["", "", ""] : [];
80
+ const githubCells = (row) => hasGithub ? [row.github?.pull_requests_opened, row.github?.pull_requests_merged, row.github?.commits].map((count) => count === null || count === undefined ? "n/a" : String(count)) : [];
81
+ const widths = [nameWidth, 8, 15, 12, ...(hasGithub ? [10, 10, 8] : []), 10, 10, 12, 14];
75
82
  const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
76
- printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
83
+ printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(hasGithub ? ["PRs opened", "PRs merged", "Commits"] : []), ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
77
84
  for (const row of body.people ?? []) {
78
85
  printRow([
79
86
  (row.display_name ?? row.email ?? "Unknown").slice(0, 20),
80
87
  formatCount(row.tokens_total),
81
88
  formatUsageDollars(row.api_list_price_equivalent_usd),
82
89
  `${row.sessions_extracted}/${row.sessions_observed}`,
90
+ ...githubCells(row),
83
91
  ...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
84
92
  ]);
85
93
  if (command.bySubject)
86
94
  for (const subject of row.subjects ?? []) {
87
- printRow([` ${subject.repo_label} / ${subject.topic}`, formatCount(subject.tokens), formatUsageDollars(subject.api_list_price_equivalent_usd), `${subject.extracted_sessions}/${subject.sessions}`, ...(command.detail ? [subject.output, subject.input, subject.cache_read, subject.cache_creation].map(formatCount) : [])]);
95
+ printRow([` ${subject.repo_label} / ${subject.topic}`, formatCount(subject.tokens), formatUsageDollars(subject.api_list_price_equivalent_usd), `${subject.extracted_sessions}/${subject.sessions}`, ...githubBlanks, ...(command.detail ? [subject.output, subject.input, subject.cache_read, subject.cache_creation].map(formatCount) : [])]);
88
96
  for (const sub of subject.children ?? [])
89
- printRow([` ${sub.subtopic}`, formatCount(sub.tokens), formatUsageDollars(sub.api_list_price_equivalent_usd), `${sub.extracted_sessions}/${sub.sessions}`, ...(command.detail ? [sub.output, sub.input, sub.cache_read, sub.cache_creation].map(formatCount) : [])]);
97
+ printRow([` ${sub.subtopic}`, formatCount(sub.tokens), formatUsageDollars(sub.api_list_price_equivalent_usd), `${sub.extracted_sessions}/${sub.sessions}`, ...githubBlanks, ...(command.detail ? [sub.output, sub.input, sub.cache_read, sub.cache_creation].map(formatCount) : [])]);
90
98
  }
91
99
  if (command.byTask || command.byRepo || command.byTopic) {
92
100
  const repos = (command.byTask ? row.tickets : command.byTopic ? row.task_types : row.repos) ?? [];
@@ -95,7 +103,7 @@ export async function runUsage(command, io) {
95
103
  writeLine(io.stdout, ` ${repo.ticket_title ?? ""} Sources: ${Object.entries(repo.binding_sources ?? {}).map(([source, count]) => `${source} ${count}`).join(", ")}`);
96
104
  if (command.byTopic && repo.confidence)
97
105
  writeLine(io.stdout, ` Confidence: ${repo.confidence.high} high, ${repo.confidence.medium} medium, ${repo.confidence.low} low`);
98
- printRow([` ${repo.repo_label}`, formatCount(repo.tokens), formatUsageDollars(repo.api_list_price_equivalent_usd), `${repo.extracted_sessions}/${repo.sessions}`, ...(command.detail ? [repo.output, repo.input, repo.cache_read, repo.cache_creation].map(formatCount) : [])]);
106
+ printRow([` ${repo.repo_label}`, formatCount(repo.tokens), formatUsageDollars(repo.api_list_price_equivalent_usd), `${repo.extracted_sessions}/${repo.sessions}`, ...githubBlanks, ...(command.detail ? [repo.output, repo.input, repo.cache_read, repo.cache_creation].map(formatCount) : [])]);
99
107
  }
100
108
  if (!command.all && repos.length > 10)
101
109
  writeLine(io.stdout, ` and ${repos.length - 10} more ${command.byTask ? "tasks" : command.byTopic ? "topics" : "repos"}`);
@@ -126,5 +134,17 @@ export async function runUsage(command, io) {
126
134
  writeLine(io.stdout, `${(body.people ?? []).reduce((n, person) => n + (person.sessions_summarized ?? 0), 0)} of ${body.coverage?.sessions_observed ?? 0} sessions summarized`);
127
135
  if (command.byTopic)
128
136
  writeLine(io.stdout, `${body.coverage?.sessions_labelled ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions labelled by topic`);
137
+ // BLI-12561: what the GitHub columns cover and what they leave out. A
138
+ // dashboard that sent no block is named rather than left to look as if there
139
+ // were nothing to show.
140
+ if (body.github === undefined) {
141
+ writeLine(io.stdout, "GitHub counts: this dashboard sent none (it predates them).");
142
+ return 0;
143
+ }
144
+ const words = githubWords(body.github, body.people ?? []);
145
+ for (const line of words.lines)
146
+ writeLine(io.stdout, line);
147
+ if (words.label)
148
+ writeLine(io.stdout, words.label);
129
149
  return 0;
130
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.165",
3
+ "version": "0.2.167",
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.45",
31
- "@bli-cockpit/mcp": "0.1.83",
31
+ "@bli-cockpit/mcp": "0.1.85",
32
32
  "@bli-cockpit/telemetry-core": "0.1.57"
33
33
  },
34
34
  "repository": {