@bli-cockpit/cli 0.2.239 → 0.2.241

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.
Files changed (33) hide show
  1. package/dist/commands/cal-delete.js +13 -3
  2. package/dist/commands/cal-scope.js +71 -0
  3. package/dist/commands/cal-view-settings.js +61 -0
  4. package/dist/commands/cal.js +11 -0
  5. package/dist/commands/docs-share.js +81 -0
  6. package/dist/commands/docs.js +5 -0
  7. package/dist/commands/drills-render.js +2 -2
  8. package/dist/commands/drills.js +4 -4
  9. package/dist/commands/issue-list-page.js +101 -0
  10. package/dist/commands/issue.js +23 -5
  11. package/dist/commands/local-arg-values.js +90 -19
  12. package/dist/commands/local-args-tower-cal.js +148 -8
  13. package/dist/commands/local-args-tower-docs-msg.js +30 -3
  14. package/dist/commands/local-args-tower-drills.js +1 -1
  15. package/dist/commands/local-args-tower-manager.js +128 -28
  16. package/dist/commands/local-args-tower-msg.js +43 -6
  17. package/dist/commands/local-args-tower-work.js +74 -6
  18. package/dist/commands/local-help-commands-tower-ops.js +35 -21
  19. package/dist/commands/local-help-commands-tower.js +15 -11
  20. package/dist/commands/local-help.js +1 -1
  21. package/dist/commands/manager-fields-file.js +208 -0
  22. package/dist/commands/manager-hints.js +88 -0
  23. package/dist/commands/manager-limits.json +269 -0
  24. package/dist/commands/manager-read-reasons.js +78 -0
  25. package/dist/commands/manager-render-home.js +213 -37
  26. package/dist/commands/manager-render.js +119 -21
  27. package/dist/commands/manager-requests.js +7 -3
  28. package/dist/commands/manager.js +67 -43
  29. package/dist/commands/msg-write.js +118 -0
  30. package/dist/commands/msg.js +20 -29
  31. package/dist/commands/project.js +15 -3
  32. package/dist/commands/public-root.js +1 -1
  33. package/package.json +2 -2
@@ -5,7 +5,7 @@
5
5
  * them, adds and edits them, ticks them off, links an action to the meeting it came from, and
6
6
  * decides the AI's suggestions, the same things the manager page does.
7
7
  *
8
- * Eighteen verbs (`home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
8
+ * Nineteen verbs (`home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
9
9
  * twins (`manager_*`) use; the door passes Railway's answer through unchanged. Every write on an
10
10
  * existing item carries `--version`, the item's `updatedAt` as `list`/`show` print it: a stale one is
11
11
  * refused with the door's own sentence and nothing is written. `approve`, `dismiss` and `proposal`
@@ -16,8 +16,11 @@ import { randomUUID } from "node:crypto";
16
16
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
17
17
  import { colorEnabled, writeLine } from "./cli-io.js";
18
18
  import { localCommandHelp } from "./local-help.js";
19
- import { clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
19
+ import { savedTextLines, clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
20
20
  import { homeLines, summaryWrittenLines } from "./manager-render-home.js";
21
+ import { parseManagerArgs } from "./local-args-tower-manager.js";
22
+ import { READ_ONLY_HINTS, RESEEN_REFUSALS, itemHints, summaryHints } from "./manager-hints.js";
23
+ import { readFieldsText } from "./manager-fields-file.js";
21
24
  import { planManagerRequest } from "./manager-requests.js";
22
25
  const TAG = "[manager cli]";
23
26
  /**
@@ -26,12 +29,38 @@ const TAG = "[manager cli]";
26
29
  * giving up first while the write may still land.
27
30
  */
28
31
  export const MANAGER_DEADLINE_MS = 65_000;
29
- const staleHint = (id, timeline) => `Run \`cockpit manager show ${id}${timeline ? " --timeline" : ""}\` for the current version and try again.`;
30
- /** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
31
32
  /** The verbs that change an existing item. */
32
- const WRITES = new Set(["summary", "edit", "done", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
33
- const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
34
- export async function runManager(command, io) {
33
+ const WRITES = new Set(["summary", "edit", "done", "drop", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
34
+ /**
35
+ * `--fields-file`: the parser left the fields unread. Read the file (or stdin), and parse the same args again with its text,
36
+ * so the file's values get exactly the flags' checks. Nothing has touched the network yet, so a bad file costs nothing.
37
+ */
38
+ async function withFieldsFile(command, io) {
39
+ if (command.fieldsFile === undefined || command.rawArgs === undefined)
40
+ return command;
41
+ const source = command.fieldsFile === "-" ? "stdin" : "file";
42
+ try {
43
+ const text = await readFieldsText(command.fieldsFile, io);
44
+ const parsed = parseManagerArgs(command.rawArgs, { fieldsText: text });
45
+ writeLine(io.stderr, `${TAG} fields read ${JSON.stringify({ source, bytes: Buffer.byteLength(text, "utf8"), verb: command.action })}`);
46
+ const { fieldsFile: _file, rawArgs: _args, ...rest } = parsed;
47
+ return { ...rest, homeDir: command.homeDir, dashboardUrl: command.dashboardUrl, json: command.json };
48
+ }
49
+ catch (error) {
50
+ const detail = error instanceof Error ? error.message : "the fields file could not be read";
51
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "fields_file_invalid", source, verb: command.action })}`);
52
+ if (command.json)
53
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: "fields_file_invalid", detail }));
54
+ else
55
+ writeLine(io.stderr, detail);
56
+ return 1;
57
+ }
58
+ }
59
+ export async function runManager(given, io) {
60
+ const loaded = await withFieldsFile(given, io);
61
+ if (typeof loaded === "number")
62
+ return loaded;
63
+ const command = loaded;
35
64
  if (command.help) {
36
65
  writeLine(io.stderr, `${TAG} help printed ${JSON.stringify({ reason: "no_verb_or_help_asked" })}`);
37
66
  writeLine(io.stdout, localCommandHelp("manager"));
@@ -107,12 +136,14 @@ function renderAnswer(command, body, color) {
107
136
  case "home": return homeLines(body, command.client ?? "<client>", color);
108
137
  case "summary": return summaryWrittenLines(body, color);
109
138
  case "run": return runLines(body);
110
- case "list": return listLines(body, timeline, color);
139
+ case "list": return listLines(body, timeline, color, new Date(), command.archived !== true && command.review !== "rejected", command.ownerFilter !== undefined || command.noOwner === true);
111
140
  case "queue": return queueLines(body, color);
112
141
  case "show": return itemDetailLines(body, timeline, color);
113
- default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color)];
142
+ default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color), ...(command.action === "add" ? savedTextLines(body) : [])];
114
143
  }
115
144
  }
145
+ /** An add answered `created: false`: the same request id came back, so the item that already existed is returned. */
146
+ const ALREADY_ADDED = "Already added earlier under this request id; nothing new was written.";
116
147
  /** What a write did, in one sentence a person reads; the item's new version follows on its row. */
117
148
  function writeSentence(command, body) {
118
149
  const as = "as you";
@@ -120,10 +151,14 @@ function writeSentence(command, body) {
120
151
  const waiting = body.item?.proposedChanges?.length ?? 0;
121
152
  const queue = waiting === 0 ? "It leaves the review queue." : `${waiting === 1 ? "Its other suggested change still waits" : `Its ${waiting} other suggested changes still wait`} in the review queue.`;
122
153
  switch (command.action) {
123
- case "add": return [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
124
- case "edit": return ["Saved your changes."];
154
+ // Only an explicit `created: false` is "not created": an older server sends no field, and that add did write.
155
+ case "add": return body.created === false ? [ALREADY_ADDED] : [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
156
+ // Railway writes nothing when every field already equals the stored one: no new version, no reviewed mark.
157
+ case "edit": return [body.unchanged === true ? "No change; nothing was written." : "Saved your changes."];
125
158
  // Railway writes nothing when the action already has that status, so its version comes back unchanged.
126
159
  case "done": return [body.item?.updatedAt === command.version ? "Already done; nothing changed." : "Marked done."];
160
+ // A dropped action was not done. Railway writes nothing when it is already dropped (`unchanged: true`).
161
+ case "drop": return [body.unchanged === true || body.item?.updatedAt === command.version ? "No change; nothing was written." : `Dropped: ${body.item?.title ?? "that action"}.`];
127
162
  case "reopen": return [body.item?.updatedAt === command.version ? "Already open; nothing changed." : "Reopened."];
128
163
  case "link": return [command.timelineItemId ? `Linked to ${body.timelineItem?.title ?? "that meeting"}.` : "Unlinked from its meeting."];
129
164
  case "approve": return [command.restore ? `Restored: ${body.item?.title ?? "that item"}, back in the lists and marked reviewed by you.` : `Approved ${as}. ${queue.replace("other ", "")}`];
@@ -170,7 +205,7 @@ function nextStep(reason, refusal, command) {
170
205
  if (reason === "not_found" && command?.id) {
171
206
  return command.timeline
172
207
  ? " Check the id with `cockpit manager list <client> --timeline`; for an action, drop --timeline."
173
- : ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "reopen" || command.action === "link" ? " (done, reopen and link are for actions only)" : ""}.`;
208
+ : ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "drop" || command.action === "reopen" || command.action === "link" ? " (done, drop, reopen and link are for actions only)" : ""}.`;
174
209
  }
175
210
  // A client-level command (home, summary, client, list, queue, add) the door cannot find the client of: "That client does not exist".
176
211
  if (reason === "not_found" && !command?.id && command?.action !== "run")
@@ -183,26 +218,30 @@ function nextStep(reason, refusal, command) {
183
218
  if ((reason === "manager_unreachable" || reason === "manager_timeout") && (command === undefined || !WRITES.has(command.action) && command.action !== "add")) {
184
219
  return " Try again in a minute; if it keeps failing, Tower's manager server may be down.";
185
220
  }
186
- switch (refusal) {
187
- // Another person's or another client's add already used this id: a fresh one makes the item.
188
- case "request_reused": return " Run the add again without --request-id to make a new item.";
189
- case "proposal_replaced": return ` Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`;
190
- case "item_archived": return ` Bring it back first: \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`;
191
- case "item_dismissed": return ` Restore it first: \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`;
192
- case "person_item_not_dismissable": return ` To delete an item a person added, use \`cockpit manager archive ${id}${flag} --version <v>\`.`;
193
- case "timeline_not_live": return " Link to a live timeline item of the same client: `cockpit manager list <client> --timeline` lists them.";
194
- case "item_not_archived": return " It is already in the lists; nothing to bring back.";
195
- default: return "";
196
- }
221
+ // The door's own 403 for a read-only key names itself as the reason.
222
+ const readOnly = READ_ONLY_HINTS[reason];
223
+ if (readOnly)
224
+ return readOnly;
225
+ const hint = refusal ? itemHints(id, flag)[refusal] : undefined;
226
+ return hint ? ` ${hint}` : "";
197
227
  }
198
228
  function refuse(door, answer, command, requestId) {
199
229
  const body = answer.body && typeof answer.body === "object" ? answer.body : {};
200
230
  const text = (value) => (typeof value === "string" && value.trim() !== "" ? value : undefined);
201
- const sentence = text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
202
231
  const refusal = text(body["refusal"]);
232
+ // `--version none` says "this client has no card yet": a card that exists is the only way the version check can refuse it, and
233
+ // the server's own sentence ("Someone changed this card after you opened it") would send the person looking for a change nobody made.
234
+ const cardExists = refusal === "summary_changed" && command?.action === "summary" && command.version === null;
235
+ // An older Tower server's status door takes only done and open: its 400 for "dropped" is a schema message, not a way forward.
236
+ const noDrop = command?.action === "drop" && answer.httpStatus === 400 && refusal === undefined;
237
+ const sentence = noDrop
238
+ ? "This Tower server can't drop actions yet; mark it done with a dated line saying why, or ask for a server update."
239
+ : cardExists
240
+ ? `This client already has a card: pass its version (\`cockpit manager home ${clientWord(command)}\` shows it).`
241
+ : text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
203
242
  const reseen = answer.reason === "invalid_state" && (refusal === undefined || RESEEN_REFUSALS.has(refusal));
204
- const hint = reseen && command?.id
205
- ? ` ${staleHint(command.id, command.timeline)}`
243
+ const hint = cardExists || noDrop ? "" : reseen && command?.id
244
+ ? ` ${itemHints(command.id, command.timeline ? " --timeline" : "").item_changed}`
206
245
  : nextStep(answer.reason, refusal, command);
207
246
  // An add with no answer from Tower (or a server error) may or may not have been made: the same request id makes the retry safe.
208
247
  const unconfirmed = requestId !== undefined && (answer.httpStatus === undefined || answer.httpStatus >= 500);
@@ -222,24 +261,9 @@ function refuse(door, answer, command, requestId) {
222
261
  return failAgentDoor(door, TAG, answer.reason, `${sentence}${stop}${hint}${retry}`, { ...(refusal ? { refusal } : {}), ...(unconfirmed ? { requestId } : {}) });
223
262
  }
224
263
  const clientWord = (command) => (command.client && /\s/.test(command.client) ? `"${command.client}"` : command.client ?? "<client>");
225
- /** The way forward after each named refusal of a "Where we are" edit (`TOWER_MANAGER_SUMMARY_REFUSALS`). */
264
+ /** The way forward after each named refusal of a "Where we are" edit (`TOWER_MANAGER_SUMMARY_REFUSALS`, `manager-hints.ts`). */
226
265
  function summaryHint(refusal, command) {
227
266
  const home = `\`cockpit manager home ${clientWord(command)} --json\``;
228
- const hints = {
229
- summary_changed: `Someone or the check wrote the card since: read it again with ${home} and retry with its summary.updatedAt as --version (none only when it has no card).`,
230
- summary_empty: "Give at least one of --health, --headline, --bullet, --next, --blocker.",
231
- summary_incomplete: "This client has no card yet: give --health, --headline and at least one --bullet together (with --version none).",
232
- summary_health_invalid: "--health is on_track, at_risk or blocked.",
233
- summary_headline_empty: "Give the headline some words.",
234
- summary_headline_too_long: "Keep --headline to 140 characters.",
235
- summary_bullets_count: "Give 1 to 5 --bullet flags; together they replace the whole list.",
236
- summary_bullet_empty: "Every --bullet needs words (after its date, when it has one).",
237
- summary_bullet_too_long: "Keep each --bullet to 160 characters.",
238
- summary_bullet_date_invalid: "Start a dated bullet with a real day, \"YYYY-MM-DD: \", or leave the date out.",
239
- summary_next_too_long: "Keep --next to 200 characters.",
240
- summary_blocker_too_long: "Keep --blocker to 200 characters.",
241
- summary_blocked_needs_blocker: "blocked needs a blocker: add --blocker <what blocks it>, or pick another --health.",
242
- };
243
- const hint = hints[refusal];
267
+ const hint = summaryHints(home)[refusal];
244
268
  return hint ? ` ${hint}` : ` Read the card with ${home} and try again.`;
245
269
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The message WRITE verbs of `cockpit msg` (BLI-21314): `edit`, `delete`, `outcome`, plus what `send`
3
+ * shares with them (the piped body, the one deleted sentence, the refusal that says how to check a write).
4
+ * Split out of `msg.ts` to keep it under the file-size ratchet. Same doors the browser calls.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
9
+ const TAG = "[msg cli]";
10
+ const READ_DEADLINE_MS = 30_000;
11
+ const WRITE_DEADLINE_MS = 30_000;
12
+ const CONTENT_MAX_CHARS = 8_000;
13
+ /** The one sentence a deleted message reads as, on every surface. */
14
+ export const DELETED_SENTENCE = "This message was deleted";
15
+ export function messageText(message) {
16
+ return message.deleted_at ? `(${DELETED_SENTENCE})` : (message.content ?? "");
17
+ }
18
+ export async function readContent(io) {
19
+ if (isInteractiveStdin(io)) {
20
+ return {
21
+ ok: false,
22
+ reason: "nothing_piped",
23
+ detail: "Pipe the message in, e.g. `echo \"hello\" | cockpit msg send general`. A message never travels on the command line.",
24
+ };
25
+ }
26
+ try {
27
+ const text = await readPipedText(io.stdin, {
28
+ maxChars: CONTENT_MAX_CHARS,
29
+ overflowMessage: `A message is limited to ${CONTENT_MAX_CHARS} characters.`,
30
+ });
31
+ return { ok: true, text: text.trim() };
32
+ }
33
+ catch (error) {
34
+ return { ok: false, reason: "content_too_long", detail: error instanceof Error ? error.message : String(error) };
35
+ }
36
+ }
37
+ /**
38
+ * Says where the write stands, then fails with the door's own reason. A conflict names the current revision;
39
+ * an unknown result says how to resolve it by request id before retrying.
40
+ */
41
+ export function refuseMsgWrite(door, answer, requestId, verb) {
42
+ const mutation = answer.body?.mutation;
43
+ writeLine(door.io.stderr, `${TAG} check the ${verb} with cockpit msg outcome ${requestId}; retry unchanged input with --request-id ${requestId}.`);
44
+ if (answer.reason === "revision_conflict") {
45
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, current_revision: mutation?.current?.revision ?? mutation?.revision ?? null })}`);
46
+ }
47
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
48
+ }
49
+ /** `cockpit msg edit <message-id> [--expected-revision <n>] [--request-id <uuid>]` — the new text is piped in. */
50
+ export async function editMessage(command, door) {
51
+ const content = await readContent(door.io);
52
+ if (!content.ok)
53
+ return failAgentDoor(door, TAG, content.reason, content.detail);
54
+ if (content.text === "")
55
+ return failAgentDoor(door, TAG, "invalid_body", "There was nothing to save. Nothing was changed.");
56
+ const requestId = command.requestId ?? randomUUID();
57
+ const answer = await askAgentDoor(door, {
58
+ path: `/api/msg/messages/${encodeURIComponent(command.targetId ?? "")}`,
59
+ method: "PATCH",
60
+ label: "msg edit",
61
+ timeoutMs: WRITE_DEADLINE_MS,
62
+ body: {
63
+ content: content.text,
64
+ request_id: requestId,
65
+ ...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
66
+ },
67
+ });
68
+ if (!answer.ok)
69
+ return refuseMsgWrite(door, answer, requestId, "edit");
70
+ const { message = null, mutation = null } = answer.body;
71
+ writeLine(door.io.stderr, `${TAG} edited ${JSON.stringify({ message_id: command.targetId, request_id: requestId, revision: mutation?.["revision"] ?? null })}`);
72
+ if (door.json)
73
+ return emitAgentDoor(door, { ok: true, message, mutation });
74
+ writeLine(door.io.stdout, `Edited ${message?.id ?? command.targetId}, revision ${String(mutation?.["revision"] ?? message?.revision ?? "?")}.`);
75
+ return 0;
76
+ }
77
+ /** `cockpit msg delete <message-id> [--expected-revision <n>] [--request-id <uuid>]` — a tombstone: the thread stays, the text goes. */
78
+ export async function deleteMessage(command, door) {
79
+ const requestId = command.requestId ?? randomUUID();
80
+ const answer = await askAgentDoor(door, {
81
+ path: `/api/msg/messages/${encodeURIComponent(command.targetId ?? "")}`,
82
+ method: "DELETE",
83
+ label: "msg delete",
84
+ timeoutMs: WRITE_DEADLINE_MS,
85
+ body: {
86
+ request_id: requestId,
87
+ ...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
88
+ },
89
+ });
90
+ if (!answer.ok)
91
+ return refuseMsgWrite(door, answer, requestId, "delete");
92
+ const { message = null, mutation = null } = answer.body;
93
+ writeLine(door.io.stderr, `${TAG} deleted ${JSON.stringify({ message_id: command.targetId, request_id: requestId, reason: mutation?.["reason"] ?? "ok" })}`);
94
+ if (door.json)
95
+ return emitAgentDoor(door, { ok: true, message, mutation });
96
+ writeLine(door.io.stdout, `Deleted ${command.targetId} (${DELETED_SENTENCE.toLowerCase()}; its thread and replies stay).`);
97
+ return 0;
98
+ }
99
+ /** `cockpit msg outcome <request-uuid>` — your own write receipt. No receipt is `unknown_check_again`, never "nothing changed". */
100
+ export async function messageOutcome(command, door) {
101
+ const answer = await askAgentDoor(door, {
102
+ path: `/api/msg/mutations/${encodeURIComponent(command.targetId ?? "")}`,
103
+ method: "GET",
104
+ label: "msg outcome",
105
+ timeoutMs: READ_DEADLINE_MS,
106
+ });
107
+ if (!answer.ok)
108
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
109
+ const mutation = answer.body.mutation ?? {};
110
+ writeLine(door.io.stderr, `${TAG} outcome ${JSON.stringify({ request_id: command.targetId, outcome: mutation["outcome"] ?? null })}`);
111
+ if (door.json)
112
+ return emitAgentDoor(door, { ok: true, mutation });
113
+ const outcome = String(mutation["outcome"] ?? "unknown_check_again");
114
+ writeLine(door.io.stdout, outcome === "unknown_check_again"
115
+ ? "Unknown: Tower has no receipt for that request yet. It may still be in flight; check again before retrying, and retry with the same --request-id."
116
+ : `${outcome}${mutation["reason"] ? ` (${String(mutation["reason"])})` : ""}${mutation["message_id"] ? `, message ${String(mutation["message_id"])}` : ""}.`);
117
+ return 0;
118
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
3
3
  *
4
- * Eight verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
4
+ * Eleven 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
  *
@@ -15,14 +15,15 @@
15
15
  * silently adding a stranger. `dm` does not have to name the caller — the
16
16
  * door unions them in.
17
17
  */
18
+ import { randomUUID } from "node:crypto";
18
19
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
- import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
+ import { writeLine } from "./cli-io.js";
20
21
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
21
22
  import { markReadThrough } from "./msg-read-line.js";
23
+ import { deleteMessage, editMessage, messageOutcome, messageText, readContent, refuseMsgWrite } from "./msg-write.js";
22
24
  const TAG = "[msg cli]";
23
25
  const READ_DEADLINE_MS = 30_000;
24
26
  const WRITE_DEADLINE_MS = 30_000;
25
- const CONTENT_MAX_CHARS = 8_000;
26
27
  export async function runMsg(command, io) {
27
28
  const door = await openAgentDoor("msg", command, io);
28
29
  switch (command.action) {
@@ -36,6 +37,12 @@ export async function runMsg(command, io) {
36
37
  return markUnread(command, door);
37
38
  case "send":
38
39
  return sendMessage(command, door);
40
+ case "edit":
41
+ return editMessage(command, door);
42
+ case "delete":
43
+ return deleteMessage(command, door);
44
+ case "outcome":
45
+ return messageOutcome(command, door);
39
46
  case "thread":
40
47
  return readThread(command, door);
41
48
  case "create":
@@ -180,7 +187,7 @@ async function readChannel(command, door) {
180
187
  for (const message of [...messages].reverse()) {
181
188
  // An imported Slack message has no Tower user; its Slack name is the label.
182
189
  const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
183
- writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
190
+ writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${messageText(message)}`);
184
191
  }
185
192
  if (readMove) {
186
193
  writeLine(readMove.ok ? door.io.stdout : door.io.stderr, readMove.ok
@@ -243,29 +250,10 @@ async function readThread(command, door) {
243
250
  }
244
251
  for (const message of [...messages].reverse()) {
245
252
  const who = message.agent_label ?? message.user_id;
246
- writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
253
+ writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${messageText(message)}`);
247
254
  }
248
255
  return 0;
249
256
  }
250
- async function readContent(io) {
251
- if (isInteractiveStdin(io)) {
252
- return {
253
- ok: false,
254
- reason: "nothing_piped",
255
- detail: "Pipe the message in, e.g. `echo \"hello\" | cockpit msg send general`. A message never travels on the command line.",
256
- };
257
- }
258
- try {
259
- const text = await readPipedText(io.stdin, {
260
- maxChars: CONTENT_MAX_CHARS,
261
- overflowMessage: `A message is limited to ${CONTENT_MAX_CHARS} characters.`,
262
- });
263
- return { ok: true, text: text.trim() };
264
- }
265
- catch (error) {
266
- return { ok: false, reason: "content_too_long", detail: error instanceof Error ? error.message : String(error) };
267
- }
268
- }
269
257
  async function sendMessage(command, door) {
270
258
  const ref = command.channelRef ?? "";
271
259
  const resolved = await resolveChannelId(door, ref);
@@ -278,6 +266,8 @@ async function sendMessage(command, door) {
278
266
  return failAgentDoor(door, TAG, content.reason, content.detail);
279
267
  if (content.text === "")
280
268
  return failAgentDoor(door, TAG, "invalid_body", "There was nothing to send. Nothing was sent.");
269
+ // BLI-21314: every send carries a request id, so a lost response can be checked and a retry posts once.
270
+ const requestId = command.requestId ?? randomUUID();
281
271
  const answer = await askAgentDoor(door, {
282
272
  path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages`,
283
273
  method: "POST",
@@ -285,15 +275,16 @@ async function sendMessage(command, door) {
285
275
  timeoutMs: WRITE_DEADLINE_MS,
286
276
  body: {
287
277
  content: content.text,
278
+ request_id: requestId,
288
279
  ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
289
280
  },
290
281
  });
291
282
  if (!answer.ok)
292
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
293
- const message = answer.body.message ?? null;
294
- writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, chars: content.text.length })}`);
283
+ return refuseMsgWrite(door, answer, requestId, "send");
284
+ const { message = null, mutation = null } = answer.body;
285
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, request_id: requestId, chars: content.text.length })}`);
295
286
  if (door.json)
296
- return emitAgentDoor(door, { ok: true, message });
287
+ return emitAgentDoor(door, { ok: true, message, mutation });
297
288
  writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
298
289
  return 0;
299
290
  }
@@ -386,7 +377,7 @@ async function showMessage(command, door) {
386
377
  writeLine(door.io.stdout, body.href ?? "Message context");
387
378
  for (const page of [body.channelPage, body.threadPage]) {
388
379
  for (const row of [...(page?.messages ?? [])].reverse())
389
- writeLine(door.io.stdout, `${row.id} ${row.created_at} ${row.content ?? ""}`);
380
+ writeLine(door.io.stdout, `${row.id} ${row.created_at} ${messageText(row)}`);
390
381
  if (page?.nextCursor)
391
382
  writeLine(door.io.stdout, `More history: --before ${page.nextCursor}`);
392
383
  }
@@ -10,21 +10,30 @@
10
10
  */
11
11
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
12
12
  import { writeLine } from "./cli-io.js";
13
+ import { pageFieldsOf, pageFooter } from "./issue-list-page.js";
13
14
  const TAG = "[project cli]";
14
15
  const READ_DEADLINE_MS = 30_000;
15
16
  export async function runProject(command, io) {
16
17
  const door = await openAgentDoor("project", command, io);
18
+ const params = new URLSearchParams();
19
+ if (command.includeArchived)
20
+ params.set("include_archived", "true");
21
+ if (command.limit !== undefined)
22
+ params.set("limit", String(command.limit));
23
+ if (command.cursor)
24
+ params.set("cursor", command.cursor);
17
25
  const answer = await askAgentDoor(door, {
18
- path: `/api/work/projects${command.includeArchived ? "?include_archived=true" : ""}`,
26
+ path: `/api/work/projects${params.toString() ? `?${params.toString()}` : ""}`,
19
27
  method: "GET",
20
28
  label: "project list",
21
29
  timeoutMs: READ_DEADLINE_MS,
22
30
  });
23
31
  if (!answer.ok)
24
32
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
25
- const projects = answer.body.projects ?? [];
33
+ const body = answer.body;
34
+ const projects = body.projects ?? [];
26
35
  if (door.json)
27
- return emitAgentDoor(door, { ok: true, projects });
36
+ return emitAgentDoor(door, { ok: true, projects, ...pageFieldsOf(body) });
28
37
  if (projects.length === 0) {
29
38
  writeLine(door.io.stdout, "No projects.");
30
39
  return 0;
@@ -34,5 +43,8 @@ export async function runProject(command, io) {
34
43
  }
35
44
  writeLine(door.io.stdout, "");
36
45
  writeLine(door.io.stdout, `${projects.length} project(s).`);
46
+ const footer = pageFooter(body, "projects");
47
+ if (footer)
48
+ writeLine(door.io.stdout, footer);
37
49
  return 0;
38
50
  }
@@ -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.239");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.241");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.239",
3
+ "version": "0.2.241",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.70",
31
- "@bli-cockpit/mcp": "0.1.144",
31
+ "@bli-cockpit/mcp": "0.1.146",
32
32
  "@bli-cockpit/telemetry-core": "0.1.77"
33
33
  },
34
34
  "repository": {