@bli-cockpit/cli 0.2.238 → 0.2.239

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `cockpit cal duplicate | batch | undo` (BLI-21296, calendar slice 4): the
3
+ * terminal in front of the same doors the browser's grid uses. `batch` reads
4
+ * its changes as JSON on stdin; every verb takes `--idempotency-key` so a retry
5
+ * writes once, and `--json` prints the door's answer untouched.
6
+ */
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
9
+ const TAG = "[cal cli]";
10
+ const WRITE_DEADLINE_MS = 300_000;
11
+ export async function runCalDirect(command, door) {
12
+ if (command.action === "duplicate")
13
+ return duplicate(command, door);
14
+ if (command.action === "batch")
15
+ return batch(command, door);
16
+ return undo(command, door);
17
+ }
18
+ function keyed(command) {
19
+ return {
20
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
21
+ ...(command.reason ? { reason: command.reason } : {}),
22
+ };
23
+ }
24
+ function writeReceiptLines(door, receipt) {
25
+ if (receipt.event?.account_name)
26
+ writeLine(door.io.stdout, `Account: ${receipt.event.account_name}.`);
27
+ if (receipt.event?.event_id)
28
+ writeLine(door.io.stdout, `Tower event: ${receipt.event.event_id}`);
29
+ if (receipt.change_id)
30
+ writeLine(door.io.stdout, `Change: ${receipt.change_id} (undo with: cockpit cal undo ${receipt.change_id})`);
31
+ else
32
+ writeLine(door.io.stdout, "Google took the change but Tower could not record it in the ledger, so there is no change id to undo.");
33
+ if (receipt.event?.version)
34
+ writeLine(door.io.stdout, `Version: ${receipt.event.version}`);
35
+ if (receipt.replayed)
36
+ writeLine(door.io.stdout, "This key was used before: nothing new was written; this is the first receipt.");
37
+ }
38
+ async function duplicate(command, door) {
39
+ const answer = await askAgentDoor(door, {
40
+ method: "POST",
41
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}/duplicate`,
42
+ label: "cal duplicate",
43
+ timeoutMs: WRITE_DEADLINE_MS,
44
+ body: {
45
+ ...(command.startsAt ? { starts_at: command.startsAt } : {}),
46
+ ...(command.endsAt ? { ends_at: command.endsAt } : {}),
47
+ ...(command.calendarId ? { calendar_id: command.calendarId } : {}),
48
+ ...(command.accountName ? { account: command.accountName } : {}),
49
+ ...(command.keepInvitees ? { keep_invitees: true } : {}),
50
+ ...keyed(command),
51
+ },
52
+ });
53
+ if (!answer.ok)
54
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
55
+ if (door.json)
56
+ return emitAgentDoor(door, { ok: true, ...answer.body });
57
+ const receipt = answer.body;
58
+ writeLine(door.io.stdout, `Duplicated "${receipt.event?.title ?? "the event"}". ${receipt.invitees === "kept" ? "The same guests were invited (Google emailed them)." : "No guests were invited."}`);
59
+ writeReceiptLines(door, receipt);
60
+ return 0;
61
+ }
62
+ async function readBatchBody(door) {
63
+ if (isInteractiveStdin(door.io))
64
+ return null;
65
+ try {
66
+ const parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: 200_000, overflowMessage: "The batch is too large." }));
67
+ if (Array.isArray(parsed))
68
+ return { changes: parsed };
69
+ return parsed && typeof parsed === "object" ? parsed : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ async function batch(command, door) {
76
+ const body = await readBatchBody(door);
77
+ if (!body || !Array.isArray(body.changes)) {
78
+ return failAgentDoor(door, TAG, "invalid_body", 'Pipe JSON: {"changes":[{"event_id":"…","starts_at":"…","ends_at":"…","expected_version":"…"}]} (or a bare array). Up to 25 changes, each event once.');
79
+ }
80
+ const answer = await askAgentDoor(door, {
81
+ method: "POST",
82
+ path: "/api/cal/events/batch",
83
+ label: "cal batch",
84
+ timeoutMs: WRITE_DEADLINE_MS,
85
+ body: { ...body, ...keyed(command) },
86
+ });
87
+ if (!answer.ok)
88
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
89
+ const result = answer.body;
90
+ // A script must be able to tell "all of it" from "some of it" without parsing: exit 1 unless every item was written.
91
+ const code = result.outcome === "all_committed" ? 0 : 1;
92
+ if (door.json)
93
+ return emitAgentDoor(door, { ok: code === 0, ...result }, code);
94
+ for (const item of result.items) {
95
+ writeLine(door.io.stdout, `#${item.index + 1} ${item.event_id}: ${item.outcome}${item.reason ? ` (${item.reason})` : ""}${item.change_id ? ` change ${item.change_id}` : ""}`);
96
+ }
97
+ writeLine(door.io.stdout, `${result.committed} of ${result.items.length} changed (${result.outcome.replaceAll("_", " ")}).`);
98
+ if (result.unchanged.length > 0) {
99
+ writeLine(door.io.stdout, `Left as they were: ${result.unchanged.map((item) => `#${item.index + 1} ${item.event_id}`).join(", ")}.`);
100
+ }
101
+ return code;
102
+ }
103
+ async function undo(command, door) {
104
+ const answer = await askAgentDoor(door, {
105
+ method: "POST",
106
+ path: "/api/cal/undo",
107
+ label: "cal undo",
108
+ timeoutMs: WRITE_DEADLINE_MS,
109
+ body: { ...(command.subjectId ? { change_id: command.subjectId } : {}), ...keyed(command) },
110
+ });
111
+ if (!answer.ok)
112
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
113
+ if (door.json)
114
+ return emitAgentDoor(door, { ok: true, ...answer.body });
115
+ const receipt = answer.body;
116
+ writeLine(door.io.stdout, `Undid change ${receipt.undoes_change_id}. The earlier values are back (or the created event is gone); guests, if any, were told by Google.`);
117
+ writeReceiptLines(door, receipt);
118
+ return 0;
119
+ }
@@ -25,6 +25,7 @@ import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-cale
25
25
  import { runCalDetail } from "./cal-detail.js";
26
26
  import { runCalOverlay } from "./cal-overlay.js";
27
27
  import { runCalDelete } from "./cal-delete.js";
28
+ import { runCalDirect } from "./cal-direct.js";
28
29
  import { runCalSyncStatus } from "./cal-sync-status.js";
29
30
  import { writeLine } from "./cli-io.js";
30
31
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
@@ -37,6 +38,10 @@ export async function runCal(command, io) {
37
38
  case "event":
38
39
  case "export":
39
40
  return runCalDetail(command, door);
41
+ case "duplicate":
42
+ case "batch":
43
+ case "undo":
44
+ return runCalDirect(command, door);
40
45
  case "conflicts":
41
46
  case "calendar-change":
42
47
  return runCalOverlay(command, door);
@@ -299,6 +304,8 @@ async function create(command, door) {
299
304
  ...(command.allDay ? { all_day: true } : {}),
300
305
  ...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
301
306
  ...(command.meet ? { meet: true } : {}),
307
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
308
+ ...(command.reason ? { reason: command.reason } : {}),
302
309
  time_zone: command.timeZone ?? localTimeZone(),
303
310
  },
304
311
  });
@@ -340,11 +347,28 @@ function meetingLinkText(event) {
340
347
  }
341
348
  async function changeEvent(command, door) {
342
349
  const answer = await askAgentDoor(door, { path: `/api/cal/events/${encodeURIComponent(command.subjectId)}`, method: "PATCH", label: "cal change", timeoutMs: WRITE_DEADLINE_MS,
343
- body: { ...(command.accountName ? { account: command.accountName } : {}), ...(command.title ? { title: command.title } : {}), ...(command.startsAt ? { starts_at: command.startsAt } : {}), ...(command.endsAt ? { ends_at: command.endsAt } : {}), ...(command.location ? { location: command.location } : {}) } });
350
+ body: {
351
+ ...(command.accountName ? { account: command.accountName } : {}),
352
+ ...(command.title ? { title: command.title } : {}),
353
+ ...(command.startsAt ? { starts_at: command.startsAt } : {}),
354
+ ...(command.endsAt ? { ends_at: command.endsAt } : {}),
355
+ ...(command.location ? { location: command.location } : {}),
356
+ // BLI-21296: all-day conversion either way, and the write controls.
357
+ ...(command.allDay ? { all_day: true } : command.timed ? { all_day: false } : {}),
358
+ ...(command.timeZone && (command.allDay || command.timed) ? { time_zone: command.timeZone } : {}),
359
+ ...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
360
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
361
+ ...(command.reason ? { reason: command.reason } : {}),
362
+ } });
344
363
  if (!answer.ok)
345
364
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
346
365
  if (door.json)
347
366
  return emitAgentDoor(door, { ok: true, ...answer.body });
348
- writeLine(door.io.stdout, `Changed event in ${answer.body.event?.account_name ?? "its account"}.`);
367
+ const changed = answer.body;
368
+ writeLine(door.io.stdout, `Changed event in ${changed.event?.account_name ?? "its account"}.`);
369
+ if (changed.change_id)
370
+ writeLine(door.io.stdout, `Change: ${changed.change_id} (undo with: cockpit cal undo ${changed.change_id})`);
371
+ if (changed.version)
372
+ writeLine(door.io.stdout, `Version: ${changed.version}`);
349
373
  return 0;
350
374
  }
@@ -27,6 +27,7 @@
27
27
  * `document_not_writable`, `title_too_long`, `content_too_long`,
28
28
  * `circular_parent`, and so on — never rephrased here.
29
29
  */
30
+ import { randomUUID } from "node:crypto";
30
31
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
31
32
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
32
33
  import { readDoc } from "./docs-read.js";
@@ -59,6 +60,8 @@ export async function runDocs(command, io) {
59
60
  return syncStatusDocs(command, door);
60
61
  case "export":
61
62
  return exportDoc(command, door);
63
+ case "mutation-status":
64
+ return mutationStatusDocs(command, door);
62
65
  }
63
66
  }
64
67
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -248,14 +251,41 @@ async function updateDoc(command, door) {
248
251
  && !command.clearParent) {
249
252
  return failAgentDoor(door, TAG, "invalid_body", "docs update needs at least one of --title, --visibility, --parent/--clear-parent, or a body on --body-stdin/--file.");
250
253
  }
251
- const answer = await updateDocument(command, door, resolved.id, bodyMarkdown);
254
+ // BLI-21298: every save carries a request id, so a lost response can be checked and a retry applies once.
255
+ const requestId = command.requestId ?? randomUUID();
256
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
257
+ const answer = await updateDocument(command, door, resolved.id, bodyMarkdown, requestId);
258
+ if (!answer.ok)
259
+ return refuseDocsWrite(door, answer, requestId);
260
+ const { document = {}, mutation } = answer.body;
261
+ writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id, request_id: requestId, revision: mutation?.["revision"] ?? document["revision"] })}`);
262
+ if (door.json)
263
+ return emitAgentDoor(door, { ok: true, document, mutation });
264
+ writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}), revision ${String(mutation?.["revision"] ?? document["revision"] ?? "?")}.`);
265
+ return 0;
266
+ }
267
+ /** Says where the save stands, then fails with the door's own reason (a conflict names the current revision). */
268
+ function refuseDocsWrite(door, answer, requestId) {
269
+ const mutation = answer.body?.mutation;
270
+ writeLine(door.io.stderr, `${TAG} check the save with cockpit docs mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
271
+ if (mutation?.current) {
272
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, current_revision: mutation.current.revision ?? mutation.revision })}`);
273
+ }
274
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
275
+ }
276
+ /** BLI-21298: reads only your own save receipt. A missing receipt is unknown_check_again, never "nothing changed". */
277
+ async function mutationStatusDocs(command, door) {
278
+ const answer = await askAgentDoor(door, {
279
+ path: `/api/docs/mutations/${encodeURIComponent(command.docRef ?? "")}`,
280
+ method: "GET",
281
+ label: "docs mutation status",
282
+ timeoutMs: READ_DEADLINE_MS,
283
+ });
252
284
  if (!answer.ok)
253
285
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
254
- const document = answer.body.document ?? {};
255
- writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
256
286
  if (door.json)
257
- return emitAgentDoor(door, { ok: true, document });
258
- writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
287
+ return emitAgentDoor(door, { ok: true, ...answer.body });
288
+ writeLine(door.io.stdout, JSON.stringify(answer.body));
259
289
  return 0;
260
290
  }
261
291
  /**
@@ -327,13 +357,15 @@ async function deleteOneDocument(door, ref, id) {
327
357
  return { ref, ok: false, reason: answer.reason, detail: answer.detail };
328
358
  return { ref, ok: true, id };
329
359
  }
330
- async function updateDocument(command, door, documentId, bodyMarkdown) {
360
+ async function updateDocument(command, door, documentId, bodyMarkdown, requestId) {
331
361
  return askAgentDoor(door, {
332
362
  path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
333
363
  method: "PATCH",
334
364
  label: "docs update",
335
365
  timeoutMs: WRITE_DEADLINE_MS,
336
366
  body: {
367
+ request_id: requestId,
368
+ ...(command.expectedRevision !== undefined ? { expected_revision: command.expectedRevision } : {}),
337
369
  ...(command.title !== undefined ? { title: command.title } : {}),
338
370
  ...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
339
371
  ...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * `cockpit ticket versions <id>` and `cockpit ticket restore <id> --version <n>`
4
+ * (BLI-21300): the saved descriptions of one ticket, and the write that puts an
5
+ * older one back as a NEW version. Same doors the browser uses
6
+ * (`GET .../versions`, `POST .../versions/restore`).
7
+ *
8
+ * `restore` takes the version NUMBER the person read in `versions`, looks up
9
+ * that version's id in the same list, and sends the id: the door never guesses
10
+ * "the previous one". A number the list does not carry is refused here with
11
+ * the numbers that do exist, before anything is written.
12
+ */
13
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
14
+ import { writeLine } from "./cli-io.js";
15
+ import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
16
+ export async function listIssueVersions(command, door) {
17
+ const ref = command.issueRef ?? "";
18
+ const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
19
+ const answer = await askAgentDoor(door, {
20
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions${query}`,
21
+ method: "GET",
22
+ label: "issue versions",
23
+ timeoutMs: READ_DEADLINE_MS,
24
+ });
25
+ if (!answer.ok)
26
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
27
+ const versions = answer.body.versions ?? [];
28
+ console.error(`${TAG} versions ${JSON.stringify({ count: versions.length })}`);
29
+ if (door.json)
30
+ return emitAgentDoor(door, { ok: true, versions });
31
+ if (versions.length === 0) {
32
+ writeLine(door.io.stdout, `No saved description versions for ${ref}.`);
33
+ return 0;
34
+ }
35
+ for (const row of versions) {
36
+ const from = row.restored_from_version ? ` (restored from ${row.restored_from_version})` : "";
37
+ writeLine(door.io.stdout, `v${row.version} ${row.id} ${row.created_at} ${row.source}${from} ${row.markdown.length} chars`);
38
+ }
39
+ writeLine(door.io.stdout, "");
40
+ writeLine(door.io.stdout, `${versions.length} version(s), newest first. Restore one with: cockpit ticket restore ${ref} --version <n>`);
41
+ return 0;
42
+ }
43
+ export async function restoreIssueVersion(command, door) {
44
+ const ref = command.issueRef ?? "";
45
+ const wanted = command.versionNumber;
46
+ const listed = await askAgentDoor(door, {
47
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions?limit=500`,
48
+ method: "GET",
49
+ label: "issue versions",
50
+ timeoutMs: READ_DEADLINE_MS,
51
+ });
52
+ if (!listed.ok)
53
+ return failAgentDoor(door, TAG, listed.reason, listed.detail);
54
+ const versions = listed.body.versions ?? [];
55
+ const chosen = versions.find(row => row.version === wanted);
56
+ if (!chosen) {
57
+ const have = versions.map(row => row.version).join(", ") || "none";
58
+ console.error(`${TAG} restore refused ${JSON.stringify({ reason: "version_not_found", requested: wanted ?? null, available: versions.length })}`);
59
+ return failAgentDoor(door, TAG, "version_not_found", `${ref} has no version ${wanted}. Versions: ${have}.`);
60
+ }
61
+ const requestId = command.requestId ?? randomUUID();
62
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision, version: chosen.version })}`);
63
+ let answer;
64
+ try {
65
+ answer = await askAgentDoor(door, {
66
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions/restore`,
67
+ method: "POST",
68
+ label: "issue restore",
69
+ timeoutMs: WRITE_DEADLINE_MS,
70
+ body: { request_id: requestId, expected_revision: command.expectedRevision, version_id: chosen.id },
71
+ });
72
+ }
73
+ catch {
74
+ console.error(`${TAG} response lost`, JSON.stringify({ reason: "mutation_unknown" }));
75
+ writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
76
+ return failAgentDoor(door, TAG, "mutation_unknown", "Result unknown. Check this request ID before retrying with identical input.");
77
+ }
78
+ if (!answer.ok) {
79
+ writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
80
+ const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
81
+ if (door.json)
82
+ return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
83
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
84
+ }
85
+ const body = answer.body;
86
+ const newVersion = body.mutation?.description_version;
87
+ writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ restored_version: chosen.version, new_version: newVersion ?? null })}`);
88
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
89
+ if (door.json)
90
+ return emitAgentDoor(door, { ok: true, issue: body.issue, mutation: body.mutation });
91
+ writeLine(door.io.stdout, `Restored version ${chosen.version} of ${ref} as new version ${newVersion ?? "?"}.`);
92
+ return 0;
93
+ }
@@ -33,6 +33,7 @@ import { readTicketBoard } from "./tickets-board.js";
33
33
  import { showIssue } from "./issue-show.js";
34
34
  import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
35
35
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
36
+ import { listIssueVersions, restoreIssueVersion } from "./issue-versions.js";
36
37
  export async function runIssue(command, io) {
37
38
  const door = await openAgentDoor("issue", command, io);
38
39
  switch (command.action) {
@@ -62,6 +63,10 @@ export async function runIssue(command, io) {
62
63
  return commentIssue(command, door);
63
64
  case "history":
64
65
  return issueHistory(command, door);
66
+ case "versions":
67
+ return listIssueVersions(command, door);
68
+ case "restore":
69
+ return restoreIssueVersion(command, door);
65
70
  }
66
71
  }
67
72
  function issueLine(issue, directory) {
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
22
  const CAL_ACTIONS = new Set([
23
- "event", "export", "conflicts", "calendar-change",
23
+ "event", "export", "duplicate", "batch", "undo", "conflicts", "calendar-change",
24
24
  "today",
25
25
  "week",
26
26
  "next",
@@ -36,9 +36,9 @@ const CAL_ACTIONS = new Set([
36
36
  "delete",
37
37
  ]);
38
38
  /** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
39
- const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel"]);
39
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "duplicate", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel"]);
40
40
  /** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
41
- const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
41
+ const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status", "undo"]);
42
42
  const CAL_ALLOWED_FLAGS = [
43
43
  "--home",
44
44
  "--dashboard-url",
@@ -60,12 +60,17 @@ const CAL_ALLOWED_FLAGS = [
60
60
  "--location",
61
61
  "--attendee",
62
62
  "--all-day",
63
+ "--timed",
64
+ "--keep-invitees",
63
65
  "--meet",
64
66
  "--org-visible",
65
67
  "--private",
66
68
  "--full",
67
69
  "--yes",
68
70
  "--json",
71
+ "--idempotency-key",
72
+ "--expected-version",
73
+ "--reason",
69
74
  ];
70
75
  const CAL_VALUE_FLAGS = [
71
76
  "--home",
@@ -86,6 +91,9 @@ const CAL_VALUE_FLAGS = [
86
91
  "--until",
87
92
  "--location",
88
93
  "--attendee",
94
+ "--idempotency-key",
95
+ "--expected-version",
96
+ "--reason",
89
97
  ];
90
98
  export function parseCalArgs(args) {
91
99
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -104,7 +112,7 @@ function readCalAction(values) {
104
112
  const first = values.positionals[0];
105
113
  const action = (first === undefined ? "today" : first);
106
114
  if (!CAL_ACTIONS.has(action)) {
107
- throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, next, find, calendars, add-ical, create, share, detach, sync, sync-status, or delete.`);
115
+ throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, next, find, calendars, add-ical, create, duplicate, batch, undo, share, detach, sync, sync-status, or delete.`);
108
116
  }
109
117
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
110
118
  }
@@ -114,8 +122,8 @@ function readCalSubjectOrQuery(action, rest) {
114
122
  if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
115
123
  subject = optionalNonEmpty(rest[0]);
116
124
  if (!subject) {
117
- throw new Error(action === "delete"
118
- ? "cal delete needs an event id — take one from a window verb's output (today/week/next/find)."
125
+ throw new Error(action === "delete" || action === "duplicate"
126
+ ? `cal ${action} needs an event id — take one from a window verb's output (today/week/next/find).`
119
127
  : `cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
120
128
  }
121
129
  if (rest.length > 1)
@@ -201,6 +209,11 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
201
209
  location: optionalNonEmpty(values.flags.get("--location")),
202
210
  attendees: splitAddresses(values.flags.get("--attendee")),
203
211
  allDay: values.booleans.has("--all-day"),
212
+ timed: values.booleans.has("--timed"),
213
+ keepInvitees: values.booleans.has("--keep-invitees"),
214
+ idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key")),
215
+ expectedVersion: optionalNonEmpty(values.flags.get("--expected-version")),
216
+ reason: optionalNonEmpty(values.flags.get("--reason")),
204
217
  meet: values.booleans.has("--meet"),
205
218
  orgVisible: values.booleans.has("--org-visible"),
206
219
  makePrivate: values.booleans.has("--private"),
@@ -8,13 +8,14 @@
8
8
  */
9
9
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
10
10
  import { parseDocsExportFlags } from "./local-args-docs-export.js";
11
- const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status", "export"]);
11
+ const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status", "export", "mutation-status"]);
12
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
13
  // BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
13
14
  // it takes SEVERAL references now, so it no longer shares the "exactly one"
14
15
  // rule `update`/`link` still enforce. BLI‑10555: `delete` takes several too
15
16
  // (the same door, called once per ref), so it joined `read`'s branch rather
16
17
  // than this set.
17
- const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export"]);
18
+ const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export", "mutation-status"]);
18
19
  const DOCS_ALLOWED_FLAGS = [
19
20
  "--home",
20
21
  "--dashboard-url",
@@ -22,6 +23,8 @@ const DOCS_ALLOWED_FLAGS = [
22
23
  "--parent",
23
24
  "--clear-parent",
24
25
  "--allow-empty",
26
+ "--expected-revision",
27
+ "--request-id",
25
28
  "--visibility",
26
29
  "--file",
27
30
  "--body-stdin",
@@ -41,6 +44,8 @@ const DOCS_VALUE_FLAGS = [
41
44
  "--dashboard-url",
42
45
  "--title",
43
46
  "--parent",
47
+ "--expected-revision",
48
+ "--request-id",
44
49
  "--visibility",
45
50
  "--file",
46
51
  "--query",
@@ -55,15 +60,18 @@ export function parseDocsArgs(args) {
55
60
  const { action, rest } = parseDocsAction(values.positionals);
56
61
  const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
57
62
  const { docRef, docRefs } = parseDocsDocumentReference(action, link.rest);
58
- const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
63
+ const { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
59
64
  if (action === "read")
60
65
  validateDocsReadTargets(docRefs, parentId, bodies);
66
+ if (action === "mutation-status" && !UUID_PATTERN.test(docRef ?? "")) {
67
+ throw new Error("docs mutation-status needs a request UUID (the --request-id you sent, or the one `docs update` printed).");
68
+ }
61
69
  // BLI‑10555: `delete` needs no `--parent`/`--bodies` shape, only at least
62
70
  // one reference — `read`'s emptier validation does not apply.
63
71
  if (action === "delete" && (!docRefs || docRefs.length === 0)) {
64
72
  throw new Error("docs delete needs one or more document ids/slugs.");
65
73
  }
66
- return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
74
+ return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, { requestId, expectedRevision });
67
75
  }
68
76
  /**
69
77
  * BLI-10450: `read` takes either several document references OR
@@ -115,7 +123,7 @@ function parseDocsAction(positionals) {
115
123
  const first = positionals[0];
116
124
  const action = (first === undefined ? "list" : first);
117
125
  if (!DOCS_ACTIONS.has(action)) {
118
- throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, or export.`);
126
+ throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, export, or mutation-status.`);
119
127
  }
120
128
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
121
129
  }
@@ -173,11 +181,19 @@ function parseDocsWriteFlags(action, values) {
173
181
  if (allowEmpty && action !== "update") {
174
182
  throw new Error("--allow-empty belongs to `cockpit docs update`.");
175
183
  }
184
+ // BLI-21298: the write-safety pair belongs to `update`, like the other save-shaping flags.
185
+ if ((values.flags.has("--request-id") || values.flags.has("--expected-revision")) && action !== "update") {
186
+ throw new Error("--request-id and --expected-revision belong to `cockpit docs update`.");
187
+ }
188
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
189
+ if (requestId && !UUID_PATTERN.test(requestId))
190
+ throw new Error("--request-id must be a UUID.");
191
+ const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
176
192
  const title = optionalNonEmpty(values.flags.get("--title"));
177
193
  if (action === "create" && !title) {
178
194
  throw new Error("docs create needs --title.");
179
195
  }
180
- return { visibility, clearParent, parentId, allowEmpty, title };
196
+ return { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision };
181
197
  }
182
198
  /** The flags that narrow or slice what a list or read returns. */
183
199
  function parseDocsReadFlags(action, values) {
@@ -226,7 +242,7 @@ function parseDocsOffset(raw) {
226
242
  throw new Error("--offset must be a whole number 0 or greater.");
227
243
  return offset;
228
244
  }
229
- function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link) {
245
+ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, safety) {
230
246
  return {
231
247
  kind: "docs",
232
248
  action,
@@ -238,6 +254,8 @@ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearPare
238
254
  ...(parentId ? { parentId } : {}),
239
255
  clearParent,
240
256
  ...(allowEmpty ? { allowEmpty } : {}),
257
+ ...(safety.requestId ? { requestId: safety.requestId } : {}),
258
+ ...(safety.expectedRevision === undefined ? {} : { expectedRevision: safety.expectedRevision }),
241
259
  visibility: visibility,
242
260
  filePath: optionalNonEmpty(values.flags.get("--file")),
243
261
  bodyStdin: values.booleans.has("--body-stdin"),
@@ -1,6 +1,6 @@
1
1
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
- const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show"]);
3
- const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send"]);
2
+ const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread"]);
3
+ const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send", "unread"]);
4
4
  /**
5
5
  * `--members a@x.test,b@y.test` — addresses only, split on commas, never
6
6
  * fuzzy (BLI-3749). The door resolves each one exactly, so a typo comes back
@@ -35,24 +35,40 @@ export function parseMsgArgs(args) {
35
35
  "--private",
36
36
  "--members",
37
37
  "--description",
38
+ "--from",
39
+ "--mark-read",
38
40
  "--json",
39
41
  ],
40
- valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before"],
42
+ valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before", "--from"],
41
43
  });
42
44
  const { action, rest } = parseMsgAction(values.positionals);
43
45
  if (values.flags.has("--before") && action !== "read" && action !== "thread") {
44
46
  throw new Error("--before belongs to msg read or msg thread.");
45
47
  }
48
+ const { markRead, unreadFrom } = parseMsgReadLineFlags(action, values);
46
49
  const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
47
50
  const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
48
51
  const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
49
- return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit);
52
+ return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom);
53
+ }
54
+ /** `read --mark-read` and `unread --from <message-id>` (BLI-21299): the read line, moved on purpose. */
55
+ function parseMsgReadLineFlags(action, values) {
56
+ const markRead = values.booleans.has("--mark-read");
57
+ if (markRead && action !== "read")
58
+ throw new Error("--mark-read belongs to `cockpit msg read`.");
59
+ const unreadFrom = optionalNonEmpty(values.flags.get("--from"));
60
+ if (action === "unread" && !unreadFrom) {
61
+ throw new Error("msg unread needs --from <message-id>, e.g. `cockpit msg unread general --from <message-id>`.");
62
+ }
63
+ if (unreadFrom && action !== "unread")
64
+ throw new Error("--from belongs to `cockpit msg unread`.");
65
+ return { markRead, unreadFrom };
50
66
  }
51
67
  function parseMsgAction(positionals) {
52
68
  const first = positionals[0];
53
69
  const action = (first === undefined ? "channels" : first);
54
70
  if (!MSG_ACTIONS.has(action)) {
55
- throw new Error(`Unknown msg command: ${first}. Try channels, read, send, thread, create, dm, or show.`);
71
+ throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show.`);
56
72
  }
57
73
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
58
74
  }
@@ -129,7 +145,7 @@ function parseMsgReadTarget(action, rest, values) {
129
145
  }
130
146
  return { channelRef, threadId, limit };
131
147
  }
132
- function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit) {
148
+ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom) {
133
149
  return {
134
150
  kind: "msg",
135
151
  action,
@@ -144,6 +160,8 @@ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, membe
144
160
  isPrivate,
145
161
  ...(threadId ? { threadId } : {}),
146
162
  ...(limit === undefined ? {} : { limit }),
163
+ ...(markRead ? { markRead } : {}),
164
+ ...(unreadFrom ? { unreadFrom } : {}),
147
165
  json: values.booleans.has("--json"),
148
166
  };
149
167
  }
@@ -1,13 +1,20 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  export function parseSetupArgs(args) {
3
- const values = parseNamedArgs(args, { allowedFlags: ["--person", "--compare", "--since", "--details", "--json", "--home", "--dashboard-url"], valueFlags: ["--since", "--person", "--compare", "--home", "--dashboard-url"] });
3
+ const values = parseNamedArgs(args, { allowedFlags: ["--until", "--machines", "--person", "--compare", "--since", "--details", "--json", "--home", "--dashboard-url"], valueFlags: ["--until", "--since", "--person", "--compare", "--home", "--dashboard-url"] });
4
4
  if (values.positionals.length)
5
5
  throw new Error("setup takes --person or --compare, not a subcommand.");
6
6
  const compare = optionalNonEmpty(values.flags.get("--compare"));
7
7
  if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
8
8
  throw new Error("--compare takes two people (first names or emails) separated by a comma.");
9
9
  const since = optionalNonEmpty(values.flags.get("--since"));
10
- if (since && since !== "7d" && since !== "30d" && (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?$/.test(since) || !Number.isFinite(Date.parse(since)) || new Date(since).toISOString().slice(0, 10) !== since.slice(0, 10) || Date.parse(since) >= Date.now()))
11
- throw new Error("--since takes 7d, 30d, or an ISO date before now.");
12
- return { kind: "setup", since, details: values.booleans.has("--details"), person: optionalNonEmpty(values.flags.get("--person")), compare, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
10
+ const until = optionalNonEmpty(values.flags.get("--until"));
11
+ const validDate = (value) => /^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) === value.slice(0, 10);
12
+ const end = until ? Date.parse(until) : Date.now();
13
+ const duration = since ? /^([1-9]\d*)([dh])$/.exec(since) : null;
14
+ const start = duration ? end - Number(duration[1]) * (duration[2] === "d" ? 86400000 : 3600000) : since ? Date.parse(since) : end - 30 * 86400000;
15
+ if (!Number.isFinite(end) || !Number.isFinite(start) || start >= end || (until && !validDate(until)) ||
16
+ (duration && Number(duration[1]) > (duration[2] === "d" ? 365 : 8760)) ||
17
+ (since && !duration && !validDate(since)))
18
+ throw new Error("--since takes a duration (1d, 7d, 30d, 90d) or an absolute start; --until takes an absolute end after the start.");
19
+ return { kind: "setup", since, until, machines: values.booleans.has("--machines"), details: values.booleans.has("--details"), person: optionalNonEmpty(values.flags.get("--person")), compare, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
13
20
  }
@@ -27,6 +27,8 @@ const ISSUE_ACTIONS = new Set([
27
27
  "move",
28
28
  "comment",
29
29
  "history",
30
+ "versions",
31
+ "restore",
30
32
  ]);
31
33
  /** Every verb except `list` and `create` names ONE issue first. */
32
34
  const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
@@ -36,6 +38,8 @@ const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
36
38
  "move",
37
39
  "comment",
38
40
  "history",
41
+ "versions",
42
+ "restore",
39
43
  ]);
40
44
  /**
41
45
  * The closed state vocabulary, copied verbatim from
@@ -63,6 +67,7 @@ const ISSUE_ALLOWED_FLAGS = [
63
67
  "--size",
64
68
  "--parent",
65
69
  "--file",
70
+ "--version",
66
71
  "--body-stdin",
67
72
  "--json",
68
73
  ];
@@ -82,6 +87,7 @@ const ISSUE_VALUE_FLAGS = [
82
87
  "--size",
83
88
  "--parent",
84
89
  "--file",
90
+ "--version",
85
91
  ];
86
92
  export function parseIssueArgs(args) {
87
93
  const values = parseNamedArgs(args, {
@@ -96,8 +102,8 @@ export function parseIssueArgs(args) {
96
102
  throw new Error("--request-id must be a UUID.");
97
103
  if (action === "mutation-status" && !UUID_PATTERN.test(issueRef ?? ""))
98
104
  throw new Error("mutation-status needs a request UUID.");
99
- if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment"].includes(action)) {
100
- throw new Error("Write-safety flags belong to create, update, move or comment.");
105
+ if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore"].includes(action)) {
106
+ throw new Error("Write-safety flags belong to create, update, move, comment or restore.");
101
107
  }
102
108
  validateIssueState(moveState, "issue move state must be one of: ");
103
109
  const stateFilter = readIssueStateFilter(action, values);
@@ -112,13 +118,14 @@ export function parseIssueArgs(args) {
112
118
  const estimate = readIssueSize(values);
113
119
  const assignee = readIssueAssignee(action, values);
114
120
  const limit = readIssueLimit(action, values);
115
- return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit);
121
+ const versionNumber = readIssueVersion(action, values);
122
+ return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber);
116
123
  }
117
124
  function readIssueAction(values) {
118
125
  const first = values.positionals[0];
119
126
  const action = (first === undefined ? "list" : first);
120
127
  if (!ISSUE_ACTIONS.has(action)) {
121
- throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, history, or mutation-status.`);
128
+ throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, history, versions, restore, or mutation-status.`);
122
129
  }
123
130
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
124
131
  }
@@ -206,14 +213,24 @@ function readIssueAssignee(action, values) {
206
213
  }
207
214
  return assignee;
208
215
  }
216
+ /** `restore` names the version it wants; there is no "restore the previous one" (BLI-21300). */
217
+ function readIssueVersion(action, values) {
218
+ const version = optionalPositiveInteger(values.flags.get("--version"), "--version");
219
+ if (action === "restore" && version === undefined) {
220
+ throw new Error("issue restore needs --version <n>; list them with `cockpit ticket versions <id>`.");
221
+ }
222
+ if (version !== undefined && action !== "restore")
223
+ throw new Error("--version belongs to `cockpit ticket restore`.");
224
+ return version;
225
+ }
209
226
  function readIssueLimit(action, values) {
210
227
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
211
- if (limit !== undefined && action !== "list" && action !== "history") {
212
- throw new Error("--limit belongs to `cockpit issue list` or `cockpit issue history`.");
228
+ if (limit !== undefined && action !== "list" && action !== "history" && action !== "versions") {
229
+ throw new Error("--limit belongs to `cockpit issue list`, `history` or `versions`.");
213
230
  }
214
231
  return limit;
215
232
  }
216
- function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit) {
233
+ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber) {
217
234
  return {
218
235
  kind: "issue",
219
236
  requestId: optionalNonEmpty(values.flags.get("--request-id")),
@@ -230,6 +247,7 @@ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, state
230
247
  rows: optionalNonEmpty(values.flags.get("--rows")),
231
248
  project: optionalNonEmpty(values.flags.get("--project")),
232
249
  ...(limit === undefined ? {} : { limit }),
250
+ ...(versionNumber === undefined ? {} : { versionNumber }),
233
251
  title,
234
252
  ...(priority === undefined ? {} : { priority }),
235
253
  ...(estimate === undefined ? {} : { estimate }),
@@ -47,7 +47,7 @@ export const TOWER_COMMAND_HELP = [
47
47
  [
48
48
  "docs",
49
49
  [
50
- "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]] [flags]",
50
+ "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>] [flags]",
51
51
  "",
52
52
  "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
53
53
  "list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
@@ -62,7 +62,10 @@ export const TOWER_COMMAND_HELP = [
62
62
  " --json shape: one ref answers { document }, unchanged from before BLI-10450. Several refs answer { documents: [{ ref, ok, document } | { ref, ok: false, error, detail }] }.",
63
63
  "read --parent <id>|root --bodies [--limit <n>] — every document filed directly under that parent, bodies included, in ONE call (GET /api/docs/documents?parent_id=...&bodies=true). --limit here caps the DOCUMENT COUNT (1-50), not characters. --json shape: { documents: [{ id, ..., body_markdown }] }.",
64
64
  "create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. --json shape: { document }.",
65
- "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document }.",
65
+ "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] [--expected-revision <n>] [--request-id <uuid>] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document, mutation }.",
66
+ " --expected-revision <n> is the page revision you read (`docs read --json` shows document.revision). The save is one atomic step: if someone saved since, nothing is written and you get revision_conflict with the current page (--json: mutation.current). Omit it for an unconditional save. Every save advances the revision.",
67
+ " --request-id <uuid> makes a retry apply once (minted and printed when absent). Reusing it with different input is request_id_reused. If the response is lost, run `cockpit docs mutation-status <uuid>`, then retry the SAME input with the SAME id.",
68
+ "mutation-status <request-uuid> — your own save receipt: committed (with the stored revision), not_committed (with the reason) or unknown_check_again (no receipt yet; it never proves nothing changed). --json shape: { mutation }.",
66
69
  " --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
67
70
  "delete <id|slug> [<id|slug>...] — soft-deletes one or more documents (BLI-10555): the browser's own delete, sets deleted_at, never a real SQL DELETE. Recoverable — only a super_admin clearing deleted_at brings it back, there is no self-service undo. Deleting a parent does NOT delete its children; they keep their own parent_id and stay exactly where they were. Several refs resolve against ONE shared list call, then one DELETE each — one bad ref never loses the rest. --json shape: one ref answers { ok, document_id }; several answer { documents: [{ ref, ok, document_id } | { ref, ok: false, error, detail }] }.",
68
71
  "link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
@@ -77,13 +80,14 @@ export const TOWER_COMMAND_HELP = [
77
80
  [
78
81
  "msg",
79
82
  [
80
- "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
83
+ "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
81
84
  "",
82
85
  "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
83
86
  "channels — every channel you are a member of (or, as a super_admin, every channel).",
84
87
  "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
85
88
  "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
86
- "read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
89
+ "read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
90
+ "unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
87
91
  "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
88
92
  "thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
89
93
  "show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false.",
@@ -95,7 +99,7 @@ export const TOWER_COMMAND_HELP = [
95
99
  [
96
100
  "issue",
97
101
  [
98
- "Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>|mutation-status <request-uuid>] [flags]",
102
+ "Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>|versions <id>|restore <id> --version <n>|mutation-status <request-uuid>] [flags]",
99
103
  "",
100
104
  "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
101
105
  "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
@@ -109,6 +113,8 @@ export const TOWER_COMMAND_HELP = [
109
113
  "Writes accept --request-id <uuid> and --expected-revision <n>. Keep the UUID and identical fields across retries; JSON includes mutation receipt and mirror delivery separately.",
110
114
  "mutation-status <request-uuid> — check your own local commit and mirror delivery after a lost response; no receipt means unknown_check_again.",
111
115
  "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
116
+ "versions <id> [--limit <n>] — every saved description, newest first, each with its number, id, author, source and time (BLI-21300). --json shape: { versions: [{ id, version, markdown, author_id, source, restored_from_version, created_at }] }.",
117
+ "restore <id> --version <n> [--request-id <uuid>] [--expected-revision <n>] — writes version <n>'s text back as a NEW version; nothing is rewritten, so the restore can itself be undone. A stale revision conflicts and changes nothing. --json shape: { issue, mutation }.",
112
118
  "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
113
119
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
114
120
  "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
@@ -145,7 +151,7 @@ export const TOWER_COMMAND_HELP = [
145
151
  [
146
152
  "cal",
147
153
  [
148
- 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
154
+ 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
149
155
  "",
150
156
  "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
151
157
  "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
@@ -168,7 +174,10 @@ export const TOWER_COMMAND_HELP = [
168
174
  "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
169
175
  "sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
170
176
  "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
171
- "change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] — changes an event on its own account.",
177
+ "change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] [--all-day|--timed] [--tz <zone>] [--expected-version <v>] [--idempotency-key <k>] [--reason <why>] — changes an event on its own account and prints the change id (undo it with `undo`). --all-day converts to an all-day event (--at/--until are dates, --until the day AFTER the last day); --timed converts back. --expected-version refuses (version_conflict) instead of overwriting an event that changed since you read it. Recurring events are refused until scoped edits ship.",
178
+ "duplicate <event id> [--at <iso> --until <iso>] [--calendar <id>|--account <name>] [--keep-invitees] [--idempotency-key <k>] — copies ONE non-recurring event. The copy has NO guests unless you pass --keep-invitees (Google then emails each one) and no Meet room. Same times unless --at/--until are given; --calendar/--account puts the copy on another of your calendars on purpose. The same --idempotency-key twice makes one copy.",
179
+ 'batch [--idempotency-key <k>] [--reason <why>] — up to 25 changes from JSON on stdin: {"changes":[{"event_id":"…","starts_at":"…","ends_at":"…","expected_version":"…"}]} (or a bare array; fields as `change`: title, location, all_day, time_zone). Each is its own write: the output lists every item and the ones left unchanged, and the exit code is 1 unless all were written.',
180
+ "undo [<change id>] — a NEW write that puts the recorded earlier values back (or removes the event a create or duplicate made), never a deletion of history. No id means your latest change. Refuses when the event changed again since (undo_stale), when that change was already undone, and for guest changes, cancellations and calendar moves.",
172
181
  "cancel <event id> --yes [--account <name>] — same door as delete; a named account must own the event.",
173
182
  "delete <event id> --yes — removes ONE event, AT GOOGLE first, then here. Refuses without --yes: a delete can email everybody on it, exactly like create's invitations do. Google already having it gone (404/410) is answered as success, not a refusal.",
174
183
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -28,7 +28,7 @@ const COMMAND_HELP = new Map([
28
28
  // BLI-3709: the Tower nouns live next door, split along the same seam the
29
29
  // argument parsers already use — see `local-help-commands-tower.ts`.
30
30
  ...TOWER_COMMAND_HELP,
31
- ["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
31
+ ["setup", ["Usage: cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
32
32
  [
33
33
  "onboard",
34
34
  [
@@ -144,7 +144,7 @@ const COMMAND_SUMMARY_LINES = [
144
144
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
145
145
  " 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]",
146
146
  " 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>] [--attach <path>]... [--dashboard-url <url>] [--json]",
147
- " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|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]",
147
+ " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|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]",
148
148
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
149
149
  " 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]",
150
150
  ` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
@@ -168,7 +168,7 @@ const COMMAND_SUMMARY_LINES = [
168
168
  " cockpit drills [list|start|answer --confidence <1-5> [--text <words>] [--attempt <id>]|theme|theme set <theme> [--play <t>] [--watch <t>] [--train <t>]|review] [--dashboard-url <url>] [--json]",
169
169
  " cockpit manager <clients|client <client>|run <run-id>|list <client>|queue <client>|show <id>|add <client> --title <t>|edit <id> --version <v>|done|reopen|link|approve|dismiss|proposal|archive|unarchive> [--timeline] [--dashboard-url <url>] [--json]",
170
170
  " cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
171
- " cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
171
+ " cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
172
172
  " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
173
173
  " cockpit usage session <id> [--from-window <n>] [--json]",
174
174
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The read line, from a terminal (BLI-21299), split out of `msg.ts` to keep it
3
+ * under the file-size ratchet: the two fields every channel read now carries
4
+ * (`provenance`, `readBoundary`) and the `read --mark-read` step. The web page,
5
+ * MCP `msg_read` `mark_read` and this all call `POST /api/msg/channels/[id]/read`.
6
+ */
7
+ import { askAgentDoor } from "./agent-door.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ const TAG = "[msg cli]";
10
+ const WRITE_DEADLINE_MS = 30_000;
11
+ /** POST the read line to the newest top-level message of a page just read (newest first). */
12
+ export async function markReadThrough(door, channelId, messages) {
13
+ const newest = messages.find((message) => !message.thread_parent_id);
14
+ if (!newest) {
15
+ writeLine(door.io.stderr, `${TAG} mark-read skipped ${JSON.stringify({ channel_id: channelId, reason: "nothing_to_mark" })}`);
16
+ return { ok: false, reason: "nothing_to_mark", detail: "There was no top-level message on this page to mark read." };
17
+ }
18
+ const answer = await askAgentDoor(door, {
19
+ path: `/api/msg/channels/${encodeURIComponent(channelId)}/read`,
20
+ method: "POST",
21
+ label: "msg mark-read",
22
+ timeoutMs: WRITE_DEADLINE_MS,
23
+ body: { message_id: newest.id },
24
+ });
25
+ if (!answer.ok) {
26
+ writeLine(door.io.stderr, `${TAG} mark-read failed ${JSON.stringify({ channel_id: channelId, reason: answer.reason })}`);
27
+ return { ok: false, reason: answer.reason, detail: answer.detail };
28
+ }
29
+ const changed = answer.body.move?.changed === true;
30
+ writeLine(door.io.stderr, `${TAG} mark-read ${JSON.stringify({ channel_id: channelId, message_id: newest.id, changed })}`);
31
+ return { ok: true, messageId: newest.id, changed };
32
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
3
3
  *
4
- * Six verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
4
+ * Eight verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
5
5
  * collector device token through `resolveCaller({ allowDeviceToken: true })`
6
6
  * on every route — no server-side door change was needed for this terminal.
7
7
  *
@@ -18,6 +18,7 @@
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
20
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
21
+ import { markReadThrough } from "./msg-read-line.js";
21
22
  const TAG = "[msg cli]";
22
23
  const READ_DEADLINE_MS = 30_000;
23
24
  const WRITE_DEADLINE_MS = 30_000;
@@ -31,6 +32,8 @@ export async function runMsg(command, io) {
31
32
  return listChannels(door);
32
33
  case "read":
33
34
  return readChannel(command, door);
35
+ case "unread":
36
+ return markUnread(command, door);
34
37
  case "send":
35
38
  return sendMessage(command, door);
36
39
  case "thread":
@@ -151,11 +154,20 @@ async function readChannel(command, door) {
151
154
  // BLI-10449: whether this channel is Tower's own or a Slack snapshot, and
152
155
  // how fresh — the door's own sentence, printed as sent.
153
156
  const provenance = body.provenance ?? null;
157
+ const readBoundary = body.readBoundary ?? null;
154
158
  const limitApplied = body.limitApplied ?? null;
155
- if (door.json)
156
- return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage });
159
+ // `--mark-read` (BLI-21299): the person has just been shown these messages, so
160
+ // the read line moves to the newest top-level one - the only way a terminal
161
+ // moves it. Never implied by reading alone.
162
+ const readMove = command.markRead ? await markReadThrough(door, resolved.id, messages) : null;
163
+ const exitCode = readMove && !readMove.ok ? 1 : 0;
164
+ if (door.json) {
165
+ return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, readBoundary, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage, ...(readMove ? { readMove } : {}) }, exitCode);
166
+ }
157
167
  if (provenance?.sentence)
158
168
  writeLine(door.io.stdout, provenance.sentence);
169
+ if (readBoundary?.sentence)
170
+ writeLine(door.io.stdout, readBoundary.sentence);
159
171
  if (limitApplied !== null) {
160
172
  writeLine(door.io.stdout, `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.`);
161
173
  }
@@ -170,6 +182,36 @@ async function readChannel(command, door) {
170
182
  const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
171
183
  writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
172
184
  }
185
+ if (readMove) {
186
+ writeLine(readMove.ok ? door.io.stdout : door.io.stderr, readMove.ok
187
+ ? `Read line moved to ${readMove.messageId} (${readMove.changed ? "advanced" : "already at or past it"}).`
188
+ : `Read line NOT moved (${readMove.reason}): ${readMove.detail} Run the same command again to retry.`);
189
+ }
190
+ return exitCode;
191
+ }
192
+ /** `cockpit msg unread <channel> --from <message-id>` — this message and everything after it counts as unread. */
193
+ async function markUnread(command, door) {
194
+ const ref = command.channelRef ?? "";
195
+ const messageId = command.unreadFrom ?? "";
196
+ const resolved = await resolveChannelId(door, ref);
197
+ if (resolved.status === "list_failed")
198
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
199
+ if (resolved.status === "not_found")
200
+ return channelNotFound(door, ref);
201
+ const answer = await askAgentDoor(door, {
202
+ path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/read`,
203
+ method: "POST",
204
+ label: "msg unread",
205
+ timeoutMs: WRITE_DEADLINE_MS,
206
+ body: { message_id: messageId, action: "unread" },
207
+ });
208
+ if (!answer.ok)
209
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
210
+ const move = answer.body.move ?? {};
211
+ writeLine(door.io.stderr, `${TAG} unread ${JSON.stringify({ channel_id: resolved.id, message_id: messageId })}`);
212
+ if (door.json)
213
+ return emitAgentDoor(door, { ok: true, channelId: resolved.id, messageId, move });
214
+ writeLine(door.io.stdout, `Marked ${messageId} and everything after it unread in ${ref}. Read line now at ${move.lastReadAt ?? "the start"}.`);
173
215
  return 0;
174
216
  }
175
217
  async function readThread(command, door) {
@@ -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.238");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.239");
25
25
  return 0;
26
26
  }
27
27
 
@@ -3,6 +3,10 @@ import { writeLine } from "./cli-io.js";
3
3
  export async function runSetup(command, io) {
4
4
  const door = await openAgentDoor("setup", command, io);
5
5
  const query = new URLSearchParams();
6
+ if (command.until)
7
+ query.set("until", command.until);
8
+ if (command.machines)
9
+ query.set("machines", "1");
6
10
  if (command.since)
7
11
  query.set("since", command.since);
8
12
  if (command.person)
@@ -18,6 +22,10 @@ export async function runSetup(command, io) {
18
22
  const number = (value) => value === null ? "n/a" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
19
23
  // BLI-13066: the server writes what each number rests on (the share of sessions counted, the sessions judged), so the CLI, the web page and JARVIS say the same words. An older server sends none.
20
24
  const coverage = (words) => words ? ` (${words})` : "";
25
+ if (body.window)
26
+ writeLine(io.stdout, `Requested window: ${body.window.since} to ${body.window.until}. ${body.automation_basis ?? "Includes automated work"}`);
27
+ if (body.freshness)
28
+ writeLine(io.stdout, body.freshness.words);
21
29
  for (const person of body.people ?? []) {
22
30
  writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR${coverage(person.coverage_words?.tokens)} · ${person.yield === null ? "no session judged yet" : `${number(person.yield * 100)}% session yield`}${coverage(person.coverage_words?.yield)}`);
23
31
  if (person.tokens_basis)
@@ -28,6 +36,11 @@ export async function runSetup(command, io) {
28
36
  if (!person.machines.length)
29
37
  writeLine(io.stdout, " No setup received.");
30
38
  for (const machine of person.machines) {
39
+ if (machine.health) {
40
+ writeLine(io.stdout, ` Machine reading: ${machine.health.words ?? machine.health.reason}. ${machine.health.next_action?.words ?? ""}`);
41
+ for (const piece of machine.health.pieces ?? [])
42
+ writeLine(io.stdout, ` ${piece.key}: ${piece.status}${piece.reason ? ` (${piece.reason})` : ""}. ${piece.next_action?.words ?? ""}`);
43
+ }
31
44
  if (!machine.setup) {
32
45
  writeLine(io.stdout, " No readable setup from this machine yet.");
33
46
  continue;
@@ -5,6 +5,8 @@ import { writeLine } from "./cli-io.js";
5
5
  export async function runUsage(command, io) {
6
6
  const door = await openAgentDoor("usage", command, io);
7
7
  const query = new URLSearchParams({ since: command.since });
8
+ if (command.person)
9
+ query.set("person", command.person);
8
10
  addUsageGroupingFilters(command, query);
9
11
  if (command.action === "sessions")
10
12
  return runUsageSessions(command, io, door, query);
@@ -19,7 +21,7 @@ export async function runUsage(command, io) {
19
21
  writeLine(io.stdout, String(answer.body.text ?? ""));
20
22
  return 0;
21
23
  }
22
- if (command.person) {
24
+ if (command.person && !body.person_filter && !(body.people ?? []).some(person => person.identity)) {
23
25
  const email = command.person === "me" ? (await loadPairedSession("usage", command.homeDir)).email : command.person;
24
26
  if (!email)
25
27
  return failAgentDoor(door, "[usage]", "caller_email_unavailable", "The paired session has no email. Sign in again or pass --person <email>.");
@@ -65,24 +65,25 @@ function parseSourceRetryEntry(value, fieldPath) {
65
65
  function parsePendingSessionReport(value, fieldPath) {
66
66
  const record = requireRecord(value, fieldPath);
67
67
  const sessions = parseMetadataOnlySessionReportRows(record["sessions"], `${fieldPath}.sessions`);
68
+ const text = (key) => requireString(record[key], `${fieldPath}.${key}`);
68
69
  return {
69
- report_id: requireString(record["report_id"], `${fieldPath}.report_id`),
70
- created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
71
- last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
72
- dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
73
- generated_at: requireString(record["generated_at"], `${fieldPath}.generated_at`),
74
- work_context_id: requireString(record["work_context_id"], `${fieldPath}.work_context_id`),
75
- repo_label: requireString(record["repo_label"], `${fieldPath}.repo_label`),
76
- branch: requireString(record["branch"], `${fieldPath}.branch`),
77
- repo_fingerprint: requireString(record["repo_fingerprint"], `${fieldPath}.repo_fingerprint`),
70
+ report_id: text("report_id"),
71
+ created_at: text("created_at"),
72
+ last_attempt_at: text("last_attempt_at"),
73
+ dashboard_url: text("dashboard_url"),
74
+ generated_at: text("generated_at"),
75
+ work_context_id: text("work_context_id"),
76
+ repo_label: text("repo_label"),
77
+ branch: text("branch"),
78
+ repo_fingerprint: text("repo_fingerprint"),
78
79
  repo_origin_url: record["repo_origin_url"] === undefined
79
80
  ? null
80
81
  : requireNullableString(record["repo_origin_url"], `${fieldPath}.repo_origin_url`),
81
- worktree_label: requireString(record["worktree_label"], `${fieldPath}.worktree_label`),
82
- worktree_fingerprint: requireString(record["worktree_fingerprint"], `${fieldPath}.worktree_fingerprint`),
82
+ worktree_label: text("worktree_label"),
83
+ worktree_fingerprint: text("worktree_fingerprint"),
83
84
  worktree_is_primary: requireBoolean(record["worktree_is_primary"], `${fieldPath}.worktree_is_primary`),
84
85
  sessions,
85
- failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
86
+ failure_reason: text("failure_reason"),
86
87
  };
87
88
  }
88
89
  export function parseMetadataOnlySessionReportRows(value, fieldPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.238",
3
+ "version": "0.2.239",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.69",
31
- "@bli-cockpit/mcp": "0.1.143",
32
- "@bli-cockpit/telemetry-core": "0.1.76"
30
+ "@bli-cockpit/memory-mcp": "0.1.70",
31
+ "@bli-cockpit/mcp": "0.1.144",
32
+ "@bli-cockpit/telemetry-core": "0.1.77"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",