@bli-cockpit/cli 0.2.238 → 0.2.240

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.
@@ -1,23 +1,33 @@
1
1
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
2
  import { writeLine } from "./cli-io.js";
3
+ import { writeScopeFacts } from "./cal-scope.js";
3
4
  const TAG = "[cal cli]";
4
5
  const DELETE_DEADLINE_MS = 60_000;
5
6
  export async function runCalDelete(command, door) {
7
+ const query = new URLSearchParams();
8
+ if (command.accountName)
9
+ query.set("account", command.accountName);
10
+ if (command.scope)
11
+ query.set("scope", command.scope);
12
+ if (command.occurrenceStart)
13
+ query.set("occurrence_start", command.occurrenceStart);
6
14
  const answer = await askAgentDoor(door, {
7
- path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${command.accountName ? `?account=${encodeURIComponent(command.accountName)}` : ""}`,
15
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${query.size > 0 ? `?${query.toString()}` : ""}`,
8
16
  method: "DELETE",
9
17
  label: "cal delete",
10
18
  timeoutMs: DELETE_DEADLINE_MS,
11
19
  });
12
20
  if (!answer.ok)
13
21
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
14
- const deleted = answer.body.deleted;
22
+ const full = answer.body;
23
+ const deleted = full.deleted;
15
24
  if (door.json)
16
- return emitAgentDoor(door, { ok: true, deleted });
25
+ return emitAgentDoor(door, { ok: true, ...full });
17
26
  if (!deleted) {
18
27
  writeLine(door.io.stdout, "Tower answered without a result. Nothing to report.");
19
28
  return 0;
20
29
  }
30
+ writeScopeFacts(door, full);
21
31
  if (deleted.account_name)
22
32
  writeLine(door.io.stdout, `Account: ${deleted.account_name}.`);
23
33
  const alreadyGone = deleted.google === "already_gone_at_google";
@@ -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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Recurring-event scope, RSVP and event-detail flags for `cockpit cal`
3
+ * (BLI-21311, slice 5). The scope words ("this event", "this and following
4
+ * events", "all events") come from the door and are printed as it sent them;
5
+ * this module only builds the request fields and says what came back.
6
+ */
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ const TAG = "[cal cli]";
10
+ const WRITE_DEADLINE_MS = 60_000;
11
+ /** `scope` and `occurrence_start`, only when the caller gave them. */
12
+ export function scopeFields(command) {
13
+ return {
14
+ ...(command.scope ? { scope: command.scope } : {}),
15
+ ...(command.occurrenceStart ? { occurrence_start: command.occurrenceStart } : {}),
16
+ };
17
+ }
18
+ /** `color_id`, `reminders`, `visibility`, `transparency`, only when given. */
19
+ export function detailFields(command) {
20
+ return {
21
+ ...(command.colorId !== undefined ? { color_id: command.colorId } : {}),
22
+ ...(command.reminders !== undefined ? { reminders: command.reminders } : {}),
23
+ ...(command.visibility ? { visibility: command.visibility } : {}),
24
+ ...(command.transparency ? { transparency: command.transparency } : {}),
25
+ };
26
+ }
27
+ /** Prints the scope label, occurrence, split ids and notice the door answered with. */
28
+ export function writeScopeFacts(door, facts) {
29
+ if (facts.scope_label)
30
+ writeLine(door.io.stdout, `Applied to: ${facts.scope_label}${facts.occurrence_start ? ` (occurrence ${facts.occurrence_start})` : ""}.`);
31
+ if (facts.trimmed_until)
32
+ writeLine(door.io.stdout, `Series now ends: ${facts.trimmed_until}.`);
33
+ const split = facts.series_split;
34
+ if (split) {
35
+ writeLine(door.io.stdout, `New series: ${split.new_series_id ?? "unknown"}. Trimmed series: ${split.trimmed_series_id ?? "unknown"}${split.trimmed_until ? ` (until ${split.trimmed_until})` : ""}.`);
36
+ if (split.atomic === false)
37
+ writeLine(door.io.stdout, "The split ran in steps and is not atomic; check both series.");
38
+ }
39
+ if (facts.notice)
40
+ writeLine(door.io.stdout, facts.notice);
41
+ }
42
+ export async function runCalRsvp(command, door) {
43
+ const answer = await askAgentDoor(door, {
44
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}/rsvp`,
45
+ method: "POST",
46
+ label: "cal rsvp",
47
+ timeoutMs: WRITE_DEADLINE_MS,
48
+ body: {
49
+ response: command.rsvpResponse,
50
+ ...(command.rsvpComment ? { comment: command.rsvpComment } : {}),
51
+ ...(command.accountName ? { account: command.accountName } : {}),
52
+ ...scopeFields(command),
53
+ ...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
54
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
55
+ ...(command.reason ? { reason: command.reason } : {}),
56
+ },
57
+ });
58
+ if (!answer.ok)
59
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
60
+ if (door.json)
61
+ return emitAgentDoor(door, answer.body);
62
+ const body = answer.body;
63
+ const rsvp = body.rsvp;
64
+ writeLine(door.io.stdout, `RSVP ${rsvp?.response ?? command.rsvpResponse}${rsvp?.previous_response ? ` (was ${rsvp.previous_response})` : ""}${body.replayed ? " - already recorded, nothing sent again" : ""}.`);
65
+ writeScopeFacts(door, { scope_label: rsvp?.scope_label, occurrence_start: rsvp?.occurrence_start, notice: body.notice });
66
+ if (body.change_id)
67
+ writeLine(door.io.stdout, `Change: ${body.change_id}`);
68
+ if (body.version)
69
+ writeLine(door.io.stdout, `Version: ${body.version}`);
70
+ return 0;
71
+ }
@@ -25,6 +25,8 @@ 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 { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
29
+ import { runCalDirect } from "./cal-direct.js";
28
30
  import { runCalSyncStatus } from "./cal-sync-status.js";
29
31
  import { writeLine } from "./cli-io.js";
30
32
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
@@ -37,6 +39,10 @@ export async function runCal(command, io) {
37
39
  case "event":
38
40
  case "export":
39
41
  return runCalDetail(command, door);
42
+ case "duplicate":
43
+ case "batch":
44
+ case "undo":
45
+ return runCalDirect(command, door);
40
46
  case "conflicts":
41
47
  case "calendar-change":
42
48
  return runCalOverlay(command, door);
@@ -62,6 +68,8 @@ export async function runCal(command, io) {
62
68
  return runCalSyncStatus(command, door);
63
69
  case "change":
64
70
  return changeEvent(command, door);
71
+ case "rsvp":
72
+ return runCalRsvp(command, door);
65
73
  case "cancel":
66
74
  case "delete":
67
75
  return runCalDelete(command, door);
@@ -299,6 +307,9 @@ async function create(command, door) {
299
307
  ...(command.allDay ? { all_day: true } : {}),
300
308
  ...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
301
309
  ...(command.meet ? { meet: true } : {}),
310
+ ...detailFields(command),
311
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
312
+ ...(command.reason ? { reason: command.reason } : {}),
302
313
  time_zone: command.timeZone ?? localTimeZone(),
303
314
  },
304
315
  });
@@ -340,11 +351,31 @@ function meetingLinkText(event) {
340
351
  }
341
352
  async function changeEvent(command, door) {
342
353
  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 } : {}) } });
354
+ body: {
355
+ ...(command.accountName ? { account: command.accountName } : {}),
356
+ ...(command.title ? { title: command.title } : {}),
357
+ ...(command.startsAt ? { starts_at: command.startsAt } : {}),
358
+ ...(command.endsAt ? { ends_at: command.endsAt } : {}),
359
+ ...(command.location ? { location: command.location } : {}),
360
+ // BLI-21296: all-day conversion either way, and the write controls.
361
+ ...(command.allDay ? { all_day: true } : command.timed ? { all_day: false } : {}),
362
+ ...(command.timeZone && (command.allDay || command.timed) ? { time_zone: command.timeZone } : {}),
363
+ ...scopeFields(command),
364
+ ...detailFields(command),
365
+ ...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
366
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
367
+ ...(command.reason ? { reason: command.reason } : {}),
368
+ } });
344
369
  if (!answer.ok)
345
370
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
346
371
  if (door.json)
347
372
  return emitAgentDoor(door, { ok: true, ...answer.body });
348
- writeLine(door.io.stdout, `Changed event in ${answer.body.event?.account_name ?? "its account"}.`);
373
+ const changed = answer.body;
374
+ writeLine(door.io.stdout, `Changed event in ${changed.event?.account_name ?? "its account"}.`);
375
+ writeScopeFacts(door, changed);
376
+ if (changed.change_id)
377
+ writeLine(door.io.stdout, `Change: ${changed.change_id} (undo with: cockpit cal undo ${changed.change_id})`);
378
+ if (changed.version)
379
+ writeLine(door.io.stdout, `Version: ${changed.version}`);
349
380
  return 0;
350
381
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `cockpit docs share` and `cockpit docs access` — named page editors from a terminal (BLI-21313).
3
+ * A sibling of `docs.ts`, like `docs-notion.ts`.
4
+ *
5
+ * `access <id|slug>` → `GET /api/docs/documents/[id]/grants`
6
+ * `share <id|slug> --add <email>` → `POST /api/docs/documents/[id]/grants`
7
+ * `share <id|slug> --remove <email>` → `DELETE /api/docs/documents/[id]/grants`
8
+ *
9
+ * The door answers with the shared permission explanation, and every refusal keeps its own reason
10
+ * label verbatim (`private_page_grant_needs_decision`, `not_grant_manager`, `person_not_found`, ...).
11
+ * A person is an exact email; the server resolves it, so this file never guesses who someone is.
12
+ */
13
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
14
+ import { writeLine } from "./cli-io.js";
15
+ import { resolveDocId } from "./docs.js";
16
+ const TAG = "[docs cli]";
17
+ const READ_DEADLINE_MS = 30_000;
18
+ const WRITE_DEADLINE_MS = 60_000;
19
+ async function resolveOrFail(command, door) {
20
+ const ref = command.docRef ?? "";
21
+ const resolved = await resolveDocId(door, ref);
22
+ if (resolved.status === "list_failed")
23
+ return { exit: failAgentDoor(door, TAG, resolved.reason, resolved.detail) };
24
+ if (resolved.status === "not_found") {
25
+ return { exit: failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${ref}`) };
26
+ }
27
+ return { id: resolved.id };
28
+ }
29
+ function printPermission(door, permission) {
30
+ for (const line of permission.reasons)
31
+ writeLine(door.io.stdout, line);
32
+ for (const editor of permission.editors)
33
+ writeLine(door.io.stdout, ` editor: ${editor.display_name}${editor.email ? ` <${editor.email}>` : ""}`);
34
+ }
35
+ export async function accessDocs(command, door) {
36
+ const target = await resolveOrFail(command, door);
37
+ if ("exit" in target)
38
+ return target.exit;
39
+ const answer = await askAgentDoor(door, {
40
+ path: `/api/docs/documents/${encodeURIComponent(target.id)}/grants`,
41
+ method: "GET",
42
+ label: "docs access",
43
+ timeoutMs: READ_DEADLINE_MS,
44
+ });
45
+ if (!answer.ok)
46
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
47
+ const body = answer.body;
48
+ writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length })}`);
49
+ if (door.json)
50
+ return emitAgentDoor(door, { ok: true, ...body });
51
+ printPermission(door, body.permission);
52
+ for (const event of body.events ?? []) {
53
+ writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}`);
54
+ }
55
+ return 0;
56
+ }
57
+ export async function shareDocs(command, door) {
58
+ const target = await resolveOrFail(command, door);
59
+ if ("exit" in target)
60
+ return target.exit;
61
+ const adding = command.addPerson !== undefined;
62
+ const answer = await askAgentDoor(door, {
63
+ path: `/api/docs/documents/${encodeURIComponent(target.id)}/grants`,
64
+ method: adding ? "POST" : "DELETE",
65
+ label: adding ? "docs share add" : "docs share remove",
66
+ timeoutMs: WRITE_DEADLINE_MS,
67
+ body: { person: adding ? command.addPerson : command.removePerson, ...(command.reason ? { reason: command.reason } : {}) },
68
+ });
69
+ if (!answer.ok)
70
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
71
+ const body = answer.body;
72
+ writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, request_id: body.request_id })}`);
73
+ if (door.json)
74
+ return emitAgentDoor(door, { ok: true, ...body });
75
+ const person = adding ? command.addPerson : command.removePerson;
76
+ writeLine(door.io.stdout, body.outcome === "unchanged"
77
+ ? `Nothing changed: ${person} ${adding ? "is already an editor" : "was not an editor"}.`
78
+ : `${adding ? "Added" : "Removed"} ${person} ${adding ? "as an editor of" : "from the editors of"} ${target.id}.`);
79
+ printPermission(door, body.permission);
80
+ return 0;
81
+ }
@@ -27,11 +27,13 @@
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";
33
34
  import { exportDoc } from "./docs-export.js";
34
35
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
36
+ import { accessDocs, shareDocs } from "./docs-share.js";
35
37
  import { readNoteFile } from "./notes-file.js";
36
38
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
37
39
  const TAG = "[docs cli]";
@@ -59,6 +61,12 @@ export async function runDocs(command, io) {
59
61
  return syncStatusDocs(command, door);
60
62
  case "export":
61
63
  return exportDoc(command, door);
64
+ case "mutation-status":
65
+ return mutationStatusDocs(command, door);
66
+ case "share":
67
+ return shareDocs(command, door);
68
+ case "access":
69
+ return accessDocs(command, door);
62
70
  }
63
71
  }
64
72
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -248,14 +256,41 @@ async function updateDoc(command, door) {
248
256
  && !command.clearParent) {
249
257
  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
258
  }
251
- const answer = await updateDocument(command, door, resolved.id, bodyMarkdown);
259
+ // BLI-21298: every save carries a request id, so a lost response can be checked and a retry applies once.
260
+ const requestId = command.requestId ?? randomUUID();
261
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
262
+ const answer = await updateDocument(command, door, resolved.id, bodyMarkdown, requestId);
263
+ if (!answer.ok)
264
+ return refuseDocsWrite(door, answer, requestId);
265
+ const { document = {}, mutation } = answer.body;
266
+ writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id, request_id: requestId, revision: mutation?.["revision"] ?? document["revision"] })}`);
267
+ if (door.json)
268
+ return emitAgentDoor(door, { ok: true, document, mutation });
269
+ writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}), revision ${String(mutation?.["revision"] ?? document["revision"] ?? "?")}.`);
270
+ return 0;
271
+ }
272
+ /** Says where the save stands, then fails with the door's own reason (a conflict names the current revision). */
273
+ function refuseDocsWrite(door, answer, requestId) {
274
+ const mutation = answer.body?.mutation;
275
+ writeLine(door.io.stderr, `${TAG} check the save with cockpit docs mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
276
+ if (mutation?.current) {
277
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, current_revision: mutation.current.revision ?? mutation.revision })}`);
278
+ }
279
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
280
+ }
281
+ /** BLI-21298: reads only your own save receipt. A missing receipt is unknown_check_again, never "nothing changed". */
282
+ async function mutationStatusDocs(command, door) {
283
+ const answer = await askAgentDoor(door, {
284
+ path: `/api/docs/mutations/${encodeURIComponent(command.docRef ?? "")}`,
285
+ method: "GET",
286
+ label: "docs mutation status",
287
+ timeoutMs: READ_DEADLINE_MS,
288
+ });
252
289
  if (!answer.ok)
253
290
  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
291
  if (door.json)
257
- return emitAgentDoor(door, { ok: true, document });
258
- writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
292
+ return emitAgentDoor(door, { ok: true, ...answer.body });
293
+ writeLine(door.io.stdout, JSON.stringify(answer.body));
259
294
  return 0;
260
295
  }
261
296
  /**
@@ -327,13 +362,15 @@ async function deleteOneDocument(door, ref, id) {
327
362
  return { ref, ok: false, reason: answer.reason, detail: answer.detail };
328
363
  return { ref, ok: true, id };
329
364
  }
330
- async function updateDocument(command, door, documentId, bodyMarkdown) {
365
+ async function updateDocument(command, door, documentId, bodyMarkdown, requestId) {
331
366
  return askAgentDoor(door, {
332
367
  path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
333
368
  method: "PATCH",
334
369
  label: "docs update",
335
370
  timeoutMs: WRITE_DEADLINE_MS,
336
371
  body: {
372
+ request_id: requestId,
373
+ ...(command.expectedRevision !== undefined ? { expected_revision: command.expectedRevision } : {}),
337
374
  ...(command.title !== undefined ? { title: command.title } : {}),
338
375
  ...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
339
376
  ...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
@@ -21,9 +21,9 @@ export function overviewLines(body) {
21
21
  lines.push(`${body.queue.newLeft} new drill${body.queue.newLeft === 1 ? "" : "s"} waiting. Run: cockpit drills start`, "");
22
22
  else
23
23
  lines.push("You are all caught up. Nothing new is waiting.", "");
24
- lines.push(`Theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default; change it with `cockpit drills theme set <theme>`)"}`, "");
24
+ lines.push(body.theme.plain ? "Theme: Plain (no theme; if you like, pick one with `cockpit drills theme set <theme>`)" : `Theme: ${body.theme.label}`, "");
25
25
  if (body.bosses.length > 0) {
26
- lines.push("Bosses");
26
+ lines.push(body.theme.plain ? "Mistakes you are working on" : "Bosses");
27
27
  for (const boss of body.bosses)
28
28
  lines.push(` ${boss.bossName}: ${boss.progressLine}`);
29
29
  lines.push("");
@@ -114,11 +114,11 @@ async function themeShow(door) {
114
114
  writeLine(door.io.stderr, `${TAG} theme answered ${JSON.stringify({ key: body.theme.key, chosen: body.theme.chosen, ready: body.ready })}`);
115
115
  if (door.json)
116
116
  return emitAgentDoor(door, response.body);
117
- writeLine(door.io.stdout, `Your theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default)"}`);
118
- writeLine(door.io.stdout, "Choices:");
117
+ writeLine(door.io.stdout, `Your theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default: drills are shown straight, with no theme)"}`);
118
+ writeLine(door.io.stdout, "If you like, pick a theme (plain turns it off):");
119
119
  for (const choice of body.themes)
120
- writeLine(door.io.stdout, ` ${choice.key}: ${choice.blurb}${choice.key === body.suggestedTheme && !body.theme.chosen ? " (suggested)" : ""}`);
121
- writeLine(door.io.stdout, "Or use your own words. Change it with: cockpit drills theme set <theme> [--play <what you play>] [--watch <what you watch>] [--train <what you train>]");
120
+ writeLine(door.io.stdout, ` ${choice.key}: ${choice.blurb}${choice.key === body.suggestedTheme && choice.key !== "plain" && !body.theme.chosen ? " (suggested)" : ""}`);
121
+ writeLine(door.io.stdout, "Or type anything you're into. Change it with: cockpit drills theme set <theme> [--play <what you play>] [--watch <what you watch>] [--train <what you train>]");
122
122
  return 0;
123
123
  }
124
124
  async function themeSet(command, door) {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The cursor contract on the CLI (BLI-21315): the page fields a list prints,
3
+ * the extra query a page carries, and `--all`, which walks every page and
4
+ * streams one JSON object per line so a large export never buffers.
5
+ *
6
+ * The door (`GET /api/work/issues`) owns the filters, the order and the
7
+ * cursor; this file only carries them over and reports what came back. A
8
+ * walk that stops early still ends with a final line naming the coverage and
9
+ * the reason, and the command exits non-zero.
10
+ */
11
+ import { askAgentDoor } from "./agent-door.js";
12
+ import { writeLine } from "./cli-io.js";
13
+ import { READ_DEADLINE_MS, TAG, withSizeLabel } from "./issue-contracts.js";
14
+ const ALL_PAGE_SIZE = 500;
15
+ /** The additive page fields as `--json` output, only the ones the door sent. */
16
+ export function pageFieldsOf(body) {
17
+ const fields = {};
18
+ for (const key of ["next_cursor", "has_more", "coverage", "requested", "returned", "unique", "failure_reason"]) {
19
+ if (body[key] !== undefined)
20
+ fields[key] = body[key];
21
+ }
22
+ return fields;
23
+ }
24
+ /** The sentence under a text page; null when the door sent no page fields or the page was whole. */
25
+ export function pageFooter(body, what) {
26
+ if (body.coverage === undefined)
27
+ return null;
28
+ if (body.coverage === "complete")
29
+ return null;
30
+ return body.next_cursor
31
+ ? `More ${what} available (${body.returned ?? "?"} shown). Continue with --cursor ${body.next_cursor}, or use --all.`
32
+ : `More ${what} may exist; this Tower gave no cursor to continue from.`;
33
+ }
34
+ /** Filters, sort and cursor beyond the long-standing ones, as door query parameters. */
35
+ export function addIssuePageQuery(query, command) {
36
+ const pairs = [
37
+ ["label", command.label],
38
+ ["q", command.q],
39
+ ["created_from", command.createdAfter],
40
+ ["created_to", command.createdBefore],
41
+ ["updated_from", command.updatedAfter],
42
+ ["updated_to", command.updatedBefore],
43
+ ["sort", command.sort],
44
+ ["dir", command.dir],
45
+ ["cursor", command.cursor],
46
+ ];
47
+ for (const [name, value] of pairs)
48
+ if (value)
49
+ query.set(name, value);
50
+ }
51
+ /**
52
+ * `list --all`: NDJSON on stdout, one issue per line, then ONE final line
53
+ * `{"type":"coverage",...}`. Exit 0 only when the walk was complete.
54
+ */
55
+ export async function streamAllIssues(command, door, base) {
56
+ const query = new URLSearchParams(base);
57
+ query.set("limit", String(Math.min(command.limit ?? ALL_PAGE_SIZE, ALL_PAGE_SIZE)));
58
+ query.delete("cursor");
59
+ const seen = new Set();
60
+ let returned = 0;
61
+ let pages = 0;
62
+ let expected = null;
63
+ let cursor = null;
64
+ for (;;) {
65
+ if (cursor)
66
+ query.set("cursor", cursor);
67
+ const answer = await askAgentDoor(door, {
68
+ path: `/api/work/issues?${query.toString()}`,
69
+ method: "GET",
70
+ label: "issue list",
71
+ timeoutMs: READ_DEADLINE_MS,
72
+ });
73
+ if (!answer.ok) {
74
+ console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned }));
75
+ writeLine(door.io.stdout, JSON.stringify({
76
+ type: "coverage", coverage: "failed_partial", reason: answer.reason, detail: answer.detail,
77
+ pages, returned, unique: seen.size, expected, next_cursor: cursor,
78
+ }));
79
+ return 1;
80
+ }
81
+ const body = answer.body;
82
+ pages += 1;
83
+ if (pages === 1 && typeof body.counts?.total === "number")
84
+ expected = body.counts.total;
85
+ for (const issue of body.issues ?? []) {
86
+ returned += 1;
87
+ seen.add(issue.id);
88
+ writeLine(door.io.stdout, JSON.stringify(withSizeLabel(issue)));
89
+ }
90
+ console.error(`${TAG} page ${JSON.stringify({ page: pages, returned: body.issues?.length ?? 0, coverage: body.coverage ?? null })}`);
91
+ cursor = body.next_cursor ?? null;
92
+ if (!cursor)
93
+ break;
94
+ }
95
+ const exact = expected === null || expected === returned;
96
+ writeLine(door.io.stdout, JSON.stringify({
97
+ type: "coverage", coverage: "complete", pages, returned, unique: seen.size, expected,
98
+ ...(returned === seen.size && exact ? {} : { note: "returned, unique and expected do not all agree; rows changed during the walk" }),
99
+ }));
100
+ return 0;
101
+ }