@bli-cockpit/cli 0.2.241 → 0.2.243

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 (127) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +22 -11
  3. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  4. package/dist/agent-rules.js +8 -0
  5. package/dist/autostart-contract.js +12 -0
  6. package/dist/autostart-darwin-plist.js +2 -2
  7. package/dist/autostart-darwin.js +1 -1
  8. package/dist/autostart-remind.js +245 -0
  9. package/dist/autostart-self-heal.js +17 -1
  10. package/dist/autostart-windows-scripts.js +7 -7
  11. package/dist/autostart.js +16 -15
  12. package/dist/commands/approvals-check-where.js +14 -0
  13. package/dist/commands/approvals-render.js +8 -1
  14. package/dist/commands/approvals.js +3 -2
  15. package/dist/commands/autostart-command.js +10 -0
  16. package/dist/commands/autostart-heal.js +36 -1
  17. package/dist/commands/brain.js +1 -1
  18. package/dist/commands/cal-agenda.js +44 -0
  19. package/dist/commands/cal-availability-share.js +97 -0
  20. package/dist/commands/cal-booking.js +147 -0
  21. package/dist/commands/cal-join.js +62 -0
  22. package/dist/commands/cal-links.js +68 -0
  23. package/dist/commands/cal-people.js +136 -0
  24. package/dist/commands/cal-remind.js +176 -0
  25. package/dist/commands/cal-reminders.js +33 -0
  26. package/dist/commands/cal-suggest.js +89 -0
  27. package/dist/commands/cal.js +41 -1
  28. package/dist/commands/cycle-args.js +127 -0
  29. package/dist/commands/cycles.js +117 -0
  30. package/dist/commands/docs-comments-args.js +85 -0
  31. package/dist/commands/docs-comments.js +128 -0
  32. package/dist/commands/docs-files-args.js +49 -0
  33. package/dist/commands/docs-files.js +179 -0
  34. package/dist/commands/docs-formatting.js +45 -0
  35. package/dist/commands/docs-inbox-args.js +93 -0
  36. package/dist/commands/docs-inbox.js +126 -0
  37. package/dist/commands/docs-notion.js +5 -0
  38. package/dist/commands/docs-read.js +2 -1
  39. package/dist/commands/docs-recovery.js +173 -0
  40. package/dist/commands/docs-refs-args.js +104 -0
  41. package/dist/commands/docs-refs.js +153 -0
  42. package/dist/commands/docs-search-args.js +79 -0
  43. package/dist/commands/docs-search.js +64 -0
  44. package/dist/commands/docs-sync-args.js +82 -0
  45. package/dist/commands/docs-sync.js +197 -0
  46. package/dist/commands/docs-tree.js +136 -0
  47. package/dist/commands/docs.js +85 -51
  48. package/dist/commands/doctor-update.js +4 -1
  49. package/dist/commands/inbox-args.js +81 -0
  50. package/dist/commands/inbox.js +88 -0
  51. package/dist/commands/issue-batch.js +61 -0
  52. package/dist/commands/issue-relations.js +151 -0
  53. package/dist/commands/issue-write.js +38 -5
  54. package/dist/commands/issue.js +32 -1
  55. package/dist/commands/local-args-tower-approvals.js +16 -4
  56. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  57. package/dist/commands/local-args-tower-cal-links.js +42 -0
  58. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  59. package/dist/commands/local-args-tower-cal-share.js +41 -0
  60. package/dist/commands/local-args-tower-cal.js +81 -8
  61. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  62. package/dist/commands/local-args-tower-msg.js +279 -10
  63. package/dist/commands/local-args-tower-search.js +32 -5
  64. package/dist/commands/local-args-tower-work.js +110 -34
  65. package/dist/commands/local-args-tower.js +1 -0
  66. package/dist/commands/local-args.js +2 -0
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +2 -0
  69. package/dist/commands/local-help-commands-tower.js +48 -32
  70. package/dist/commands/local-help-commands.js +1 -0
  71. package/dist/commands/local-help-cycles.js +9 -0
  72. package/dist/commands/local-help-docs-files.js +10 -0
  73. package/dist/commands/local-help-docs-formatting.js +7 -0
  74. package/dist/commands/local-help-docs-inbox.js +10 -0
  75. package/dist/commands/local-help-docs-recovery.js +10 -0
  76. package/dist/commands/local-help-docs-refs.js +11 -0
  77. package/dist/commands/local-help-docs-sync.js +9 -0
  78. package/dist/commands/local-help-inbox.js +9 -0
  79. package/dist/commands/local-help-project.js +21 -0
  80. package/dist/commands/local-help-templates.js +9 -0
  81. package/dist/commands/local-help-views.js +10 -0
  82. package/dist/commands/local-help-work-notify.js +6 -0
  83. package/dist/commands/local-help.js +4 -2
  84. package/dist/commands/local.js +2 -0
  85. package/dist/commands/mcp-stdio-probe.js +1 -0
  86. package/dist/commands/msg-activity.js +134 -0
  87. package/dist/commands/msg-channel-args.js +92 -0
  88. package/dist/commands/msg-channel.js +125 -0
  89. package/dist/commands/msg-context-args.js +67 -0
  90. package/dist/commands/msg-context.js +65 -0
  91. package/dist/commands/msg-dm.js +28 -0
  92. package/dist/commands/msg-draft.js +193 -0
  93. package/dist/commands/msg-file.js +185 -0
  94. package/dist/commands/msg-mark-args.js +78 -0
  95. package/dist/commands/msg-mark.js +148 -0
  96. package/dist/commands/msg-notify-args.js +100 -0
  97. package/dist/commands/msg-notify.js +122 -0
  98. package/dist/commands/msg-schedule-args.js +114 -0
  99. package/dist/commands/msg-schedule.js +226 -0
  100. package/dist/commands/msg-search-args.js +71 -0
  101. package/dist/commands/msg-search.js +48 -0
  102. package/dist/commands/msg-write.js +15 -1
  103. package/dist/commands/msg.js +77 -39
  104. package/dist/commands/project-args.js +82 -0
  105. package/dist/commands/project.js +71 -7
  106. package/dist/commands/public-root.js +1 -1
  107. package/dist/commands/push.js +50 -0
  108. package/dist/commands/search.js +23 -0
  109. package/dist/commands/session-sync-failures.js +17 -4
  110. package/dist/commands/session-sync-health.js +7 -2
  111. package/dist/commands/session-sync-hold.js +42 -1
  112. package/dist/commands/session-sync-upload.js +2 -0
  113. package/dist/commands/session-sync.js +1 -0
  114. package/dist/commands/sync-doctor.js +14 -1
  115. package/dist/commands/sync-report.js +1 -1
  116. package/dist/commands/template-args.js +122 -0
  117. package/dist/commands/templates.js +162 -0
  118. package/dist/commands/view-args.js +131 -0
  119. package/dist/commands/views.js +138 -0
  120. package/dist/commands/work-notify-args.js +67 -0
  121. package/dist/commands/work-notify.js +63 -0
  122. package/dist/delivery-hold-notice.js +34 -0
  123. package/dist/log-rotation.js +2 -2
  124. package/dist/notifier.js +83 -0
  125. package/dist/process-runner.js +4 -0
  126. package/dist/repo-identity-git.js +1 -0
  127. package/package.json +4 -4
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The schedule verbs of `cockpit msg` (BLI-21802): `schedule`, `remind`, `scheduled` (list), `scheduled change|cancel|done`.
3
+ * Same doors the browser calls (`/api/msg/schedules`, `.../<id>`, `.../<id>/done`); `--json` prints the door's body unchanged so
4
+ * ids, states, instants and reasons match HTTP, the web page, MCP and JARVIS. `--at` is an ISO instant with an offset, or a
5
+ * wall time "YYYY-MM-DD HH:MM" in `--timezone` (this machine's zone when none is given, and the answer names it). A schedule
6
+ * is yours alone. The message body is piped in and never travels on the command line. A refusal keeps the door's reason:
7
+ * invalid_schedule_time, invalid_timezone, schedule_limit_reached, schedule_not_found, schedule_already_sent,
8
+ * schedule_revision_conflict, request_id_reused.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
12
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
13
+ const TAG = "[msg cli]";
14
+ const DEADLINE_MS = 30_000;
15
+ const BODY_MAX_CHARS = 8_000;
16
+ const LOCAL_WALL = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d$/;
17
+ function systemZone() {
18
+ try {
19
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
20
+ }
21
+ catch {
22
+ return "UTC";
23
+ }
24
+ }
25
+ /** The `at` field of a door body, plus the zone the answer should name when the person gave a wall time without one. */
26
+ function atField(command) {
27
+ const raw = (command.scheduleAt ?? "").trim();
28
+ if (LOCAL_WALL.test(raw)) {
29
+ const assumedZone = !command.timezone;
30
+ const zone = command.timezone ?? systemZone();
31
+ return { at: { local: raw, timezone: zone }, zone, assumedZone };
32
+ }
33
+ return { at: raw, zone: command.timezone ?? null, assumedZone: false };
34
+ }
35
+ function oneLine(text) {
36
+ const line = (text ?? "").split("\n")[0] ?? "";
37
+ return line.length > 100 ? `${line.slice(0, 97)}...` : line;
38
+ }
39
+ function describe(row) {
40
+ return `${row.id} ${row.state} ${row.kind} ${row.due_at} (${row.timezone}) rev ${row.revision}${row.reason ? ` ${row.reason}` : ""}`;
41
+ }
42
+ /** A write whose answer may have been lost keeps its request id: the same input with the same id stores once. */
43
+ function refuseWrite(door, answer, requestId) {
44
+ const mutation = answer.body?.mutation;
45
+ if (answer.httpStatus === undefined || answer.httpStatus >= 500) {
46
+ writeLine(door.io.stderr, `${TAG} the result is unknown. List with \`cockpit msg scheduled\`, or retry unchanged input with --request-id ${requestId}; it stores once.`);
47
+ }
48
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
49
+ }
50
+ async function readBody(command, door, required) {
51
+ if (isInteractiveStdin(door.io)) {
52
+ if (!required)
53
+ return { ok: true, text: "" };
54
+ return { ok: false, code: failAgentDoor(door, TAG, "nothing_piped", 'Pipe the message in, e.g. `echo "hello" | cockpit msg schedule ops --at "2026-11-02 09:00"`. A message never travels on the command line.') };
55
+ }
56
+ try {
57
+ const text = await readPipedText(door.io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A scheduled message is limited to ${BODY_MAX_CHARS} characters.` });
58
+ return { ok: true, text: text.trim() };
59
+ }
60
+ catch (error) {
61
+ void command;
62
+ return { ok: false, code: failAgentDoor(door, TAG, "content_too_long", error instanceof Error ? error.message : String(error)) };
63
+ }
64
+ }
65
+ function sayAssumedZone(door, zone, assumed) {
66
+ if (assumed && zone)
67
+ writeLine(door.io.stderr, `${TAG} no --timezone given; read that wall time in this machine's zone, ${zone}.`);
68
+ }
69
+ async function scheduleVerb(command, door, resolve) {
70
+ const ref = command.channelRef ?? "";
71
+ const resolved = await resolve(ref);
72
+ if (resolved.status === "list_failed")
73
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
74
+ if (resolved.status === "not_found") {
75
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
76
+ }
77
+ const body = await readBody(command, door, true);
78
+ if (!body.ok)
79
+ return body.code;
80
+ if (body.text === "")
81
+ return failAgentDoor(door, TAG, "content_too_long", "Nothing was piped in. A scheduled message is 1 to 8000 characters.");
82
+ const when = atField(command);
83
+ sayAssumedZone(door, when.zone, when.assumedZone);
84
+ const requestId = command.requestId ?? randomUUID();
85
+ const answer = await askAgentDoor(door, {
86
+ path: "/api/msg/schedules",
87
+ method: "POST",
88
+ label: "msg schedule",
89
+ timeoutMs: DEADLINE_MS,
90
+ body: {
91
+ kind: "message", channel_id: resolved.id, body: body.text, at: when.at, request_id: requestId,
92
+ ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}),
93
+ ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
94
+ },
95
+ });
96
+ if (!answer.ok)
97
+ return refuseWrite(door, answer, requestId);
98
+ const stored = answer.body.schedule;
99
+ writeLine(door.io.stderr, `${TAG} scheduled ${JSON.stringify({ schedule_id: stored?.id ?? null, channel_id: resolved.id, request_id: requestId, replayed: answer.body.replayed === true })}`);
100
+ if (door.json)
101
+ return emitAgentDoor(door, { ok: true, ...answer.body });
102
+ writeLine(door.io.stdout, `Scheduled ${stored?.id ?? "a message"} for ${stored?.due_at ?? command.scheduleAt} (${stored?.timezone ?? when.zone ?? "UTC"}) in ${ref}. List: cockpit msg scheduled.`);
103
+ return 0;
104
+ }
105
+ async function remindVerb(command, door) {
106
+ const when = atField(command);
107
+ sayAssumedZone(door, when.zone, when.assumedZone);
108
+ const requestId = command.requestId ?? randomUUID();
109
+ const answer = await askAgentDoor(door, {
110
+ path: "/api/msg/schedules",
111
+ method: "POST",
112
+ label: "msg remind",
113
+ timeoutMs: DEADLINE_MS,
114
+ body: {
115
+ kind: "reminder", message_id: command.targetId, at: when.at, request_id: requestId,
116
+ ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}),
117
+ ...(command.scheduleNote ? { note: command.scheduleNote } : {}),
118
+ },
119
+ });
120
+ if (!answer.ok)
121
+ return refuseWrite(door, answer, requestId);
122
+ const stored = answer.body.schedule;
123
+ writeLine(door.io.stderr, `${TAG} reminder ${JSON.stringify({ schedule_id: stored?.id ?? null, message_id: command.targetId, request_id: requestId })}`);
124
+ if (door.json)
125
+ return emitAgentDoor(door, { ok: true, ...answer.body });
126
+ writeLine(door.io.stdout, `Reminder ${stored?.id ?? ""} set for ${stored?.due_at ?? command.scheduleAt} (${stored?.timezone ?? when.zone ?? "UTC"}). It shows under \`cockpit msg scheduled --state fired\` when due; nothing is posted.`);
127
+ return 0;
128
+ }
129
+ async function listVerb(command, door) {
130
+ const query = new URLSearchParams();
131
+ if (command.scheduleState)
132
+ query.set("state", command.scheduleState);
133
+ if (command.before)
134
+ query.set("before", command.before);
135
+ if (command.limit)
136
+ query.set("limit", String(command.limit));
137
+ const answer = await askAgentDoor(door, {
138
+ path: `/api/msg/schedules${query.size > 0 ? `?${query.toString()}` : ""}`,
139
+ method: "GET",
140
+ label: "msg scheduled",
141
+ timeoutMs: DEADLINE_MS,
142
+ });
143
+ if (!answer.ok)
144
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
145
+ const body = answer.body;
146
+ const items = body.items ?? [];
147
+ writeLine(door.io.stderr, `${TAG} scheduled ${JSON.stringify({ state: command.scheduleState ?? "open", returned: items.length, has_more: body.hasMore === true })}`);
148
+ if (door.json)
149
+ return emitAgentDoor(door, { ok: true, ...answer.body });
150
+ for (const row of items)
151
+ writeLine(door.io.stdout, `${describe(row)} ${oneLine(row.body)}`);
152
+ if (items.length === 0)
153
+ writeLine(door.io.stdout, "Nothing scheduled.");
154
+ writeLine(door.io.stdout, body.hasMore && body.nextCursor ? `more: --before ${body.nextCursor}` : "complete");
155
+ return 0;
156
+ }
157
+ async function changeVerb(command, door) {
158
+ const body = await readBody(command, door, false);
159
+ if (!body.ok)
160
+ return body.code;
161
+ if (!command.scheduleAt && body.text === "") {
162
+ return failAgentDoor(door, TAG, "invalid_body", "Change the time with --at, the text by piping it in, or both.");
163
+ }
164
+ const when = command.scheduleAt ? atField(command) : null;
165
+ if (when)
166
+ sayAssumedZone(door, when.zone, when.assumedZone);
167
+ const answer = await askAgentDoor(door, {
168
+ path: `/api/msg/schedules/${encodeURIComponent(command.targetId ?? "")}`,
169
+ method: "PATCH",
170
+ label: "msg scheduled change",
171
+ timeoutMs: DEADLINE_MS,
172
+ body: {
173
+ ...(when ? { at: when.at, ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}) } : {}),
174
+ ...(body.text !== "" ? { body: body.text } : {}),
175
+ ...(command.scheduleRevision === undefined ? {} : { expected_revision: command.scheduleRevision }),
176
+ },
177
+ });
178
+ if (!answer.ok)
179
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, scheduleExtra(answer.body));
180
+ const result = answer.body;
181
+ writeLine(door.io.stderr, `${TAG} change ${JSON.stringify({ schedule_id: command.targetId, changed: result.changed !== false })}`);
182
+ if (door.json)
183
+ return emitAgentDoor(door, { ok: true, ...answer.body });
184
+ writeLine(door.io.stdout, result.changed === false
185
+ ? `Unchanged: ${command.targetId} already has that time and text.`
186
+ : `Changed ${command.targetId}: now due ${result.schedule?.due_at} (${result.schedule?.timezone}), revision ${result.schedule?.revision}.`);
187
+ return 0;
188
+ }
189
+ function scheduleExtra(body) {
190
+ const mutation = body?.mutation;
191
+ return mutation ? { mutation } : {};
192
+ }
193
+ async function cancelOrDone(command, door) {
194
+ const done = command.scheduleVerb === "done";
195
+ const answer = await askAgentDoor(door, {
196
+ path: `/api/msg/schedules/${encodeURIComponent(command.targetId ?? "")}${done ? "/done" : ""}`,
197
+ method: done ? "POST" : "DELETE",
198
+ label: `msg scheduled ${done ? "done" : "cancel"}`,
199
+ timeoutMs: DEADLINE_MS,
200
+ });
201
+ if (!answer.ok) {
202
+ const sentId = answer.body?.mutation?.message_id;
203
+ if (answer.reason === "schedule_already_sent" && sentId)
204
+ writeLine(door.io.stderr, `${TAG} it already went out as message ${sentId}.`);
205
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, scheduleExtra(answer.body));
206
+ }
207
+ const result = answer.body;
208
+ writeLine(door.io.stderr, `${TAG} ${done ? "done" : "cancel"} ${JSON.stringify({ schedule_id: command.targetId, changed: result.changed !== false })}`);
209
+ if (door.json)
210
+ return emitAgentDoor(door, { ok: true, ...answer.body });
211
+ const past = done ? "Marked done" : "Cancelled";
212
+ writeLine(door.io.stdout, result.changed === false ? `${past} already: ${command.targetId}.` : `${past} ${command.targetId}.`);
213
+ return 0;
214
+ }
215
+ export async function runMsgSchedule(command, door, resolve) {
216
+ if (command.action === "schedule")
217
+ return scheduleVerb(command, door, resolve);
218
+ if (command.action === "remind")
219
+ return remindVerb(command, door);
220
+ switch (command.scheduleVerb ?? "list") {
221
+ case "change": return changeVerb(command, door);
222
+ case "cancel":
223
+ case "done": return cancelOrDone(command, door);
224
+ default: return listVerb(command, door);
225
+ }
226
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The message-search flags shared by `cockpit msg search` and `cockpit search --kind msg` (BLI-21658, slice 8):
3
+ * `--from <email|me>` (repeatable), `--in <channel>`, `--since` / `--until` (`YYYY-MM-DD`, both inclusive local
4
+ * days), `--timezone <IANA>`, `--replies` or `--top-level`, `--has-file`. One reader, one query-string writer, so
5
+ * the two commands cannot mean different things. The door checks the values; this file only checks shape.
6
+ */
7
+ import { optionalNonEmpty } from "./local-arg-values.js";
8
+ export const MSG_SEARCH_VALUE_FLAGS = ["--in", "--since", "--until"];
9
+ export const MSG_SEARCH_BOOLEAN_FLAGS = ["--replies", "--top-level", "--has-file"];
10
+ /** Flags only a message search reads (`--from` and `--timezone` are shared with other verbs, so they are not listed). */
11
+ export const MSG_SEARCH_ONLY_FLAGS = [...MSG_SEARCH_VALUE_FLAGS, ...MSG_SEARCH_BOOLEAN_FLAGS];
12
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
13
+ function machineZone() {
14
+ try {
15
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
16
+ }
17
+ catch {
18
+ return "UTC";
19
+ }
20
+ }
21
+ function day(flag, value) {
22
+ const given = optionalNonEmpty(value);
23
+ if (given === undefined)
24
+ return undefined;
25
+ if (!DAY.test(given))
26
+ throw new Error(`${flag} takes a day written YYYY-MM-DD, not "${given}".`);
27
+ return given;
28
+ }
29
+ /** Reads the filter flags. The machine's IANA zone is sent unless `--timezone` is given. */
30
+ export function readMsgSearchFilters(values) {
31
+ const replies = values.booleans.has("--replies");
32
+ const topLevel = values.booleans.has("--top-level");
33
+ if (replies && topLevel)
34
+ throw new Error("Pick one of --replies or --top-level.");
35
+ const from = (values.flagValues.get("--from") ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "");
36
+ const channel = optionalNonEmpty(values.flags.get("--in"));
37
+ const since = day("--since", values.flags.get("--since"));
38
+ const until = day("--until", values.flags.get("--until"));
39
+ if (since && until && since > until)
40
+ throw new Error(`--since ${since} is after --until ${until}.`);
41
+ return {
42
+ ...(from.length > 0 ? { from } : {}),
43
+ ...(channel ? { in: channel } : {}),
44
+ ...(since ? { since } : {}),
45
+ ...(until ? { until } : {}),
46
+ timezone: optionalNonEmpty(values.flags.get("--timezone")) ?? machineZone(),
47
+ ...(replies ? { thread: "reply" } : topLevel ? { thread: "root" } : {}),
48
+ ...(values.booleans.has("--has-file") ? { hasFile: true } : {}),
49
+ };
50
+ }
51
+ /** True when a flag that narrows the search was given (the zone alone does not narrow). */
52
+ export function hasMsgSearchFilterFlags(filters) {
53
+ return Boolean(filters.from || filters.in || filters.since || filters.until || filters.thread || filters.hasFile);
54
+ }
55
+ /** The structured query parameters `GET /api/msg/search` and `GET /api/search` read. */
56
+ export function appendMsgSearchParams(params, filters) {
57
+ for (const person of filters.from ?? [])
58
+ params.append("from", person);
59
+ if (filters.in)
60
+ params.set("channel", filters.in);
61
+ if (filters.since)
62
+ params.set("since", filters.since);
63
+ if (filters.until)
64
+ params.set("until", filters.until);
65
+ if (filters.timezone)
66
+ params.set("tz", filters.timezone);
67
+ if (filters.thread)
68
+ params.set("thread", filters.thread);
69
+ if (filters.hasFile)
70
+ params.set("has", "file");
71
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cockpit msg search [words] [--from <email|me>]... [--in <channel>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
3
+ * [--timezone <zone>] [--replies|--top-level] [--has-file] [--before <cursor>] [--limit <n>] [--json]` (BLI-21658).
4
+ *
5
+ * The door the browser's message box presses (`GET /api/msg/search`): newest first, paged by `nextCursor`. `--json`
6
+ * prints the door's body unchanged so ids, filters and paging match HTTP. Split out of `msg.ts` for the file-size ratchet.
7
+ */
8
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
9
+ import { writeLine } from "./cli-io.js";
10
+ import { appendMsgSearchParams } from "./msg-search-args.js";
11
+ const TAG = "[msg cli]";
12
+ const READ_DEADLINE_MS = 30_000;
13
+ const SNIPPET_LENGTH = 140;
14
+ function snippet(content) {
15
+ const flat = (content ?? "").replace(/\s+/g, " ").trim();
16
+ if (flat === "")
17
+ return "(attachment, no text)";
18
+ return flat.length > SNIPPET_LENGTH ? `${flat.slice(0, SNIPPET_LENGTH - 3)}...` : flat;
19
+ }
20
+ export async function runMsgSearch(command, door) {
21
+ const params = new URLSearchParams({ q: command.searchQuery ?? "" });
22
+ appendMsgSearchParams(params, command.searchFilters ?? {});
23
+ if (command.before)
24
+ params.set("before", command.before);
25
+ if (command.limit)
26
+ params.set("limit", String(command.limit));
27
+ const answer = await askAgentDoor(door, {
28
+ path: `/api/msg/search?${params.toString()}`,
29
+ method: "GET",
30
+ label: "msg search",
31
+ timeoutMs: READ_DEADLINE_MS,
32
+ });
33
+ if (!answer.ok)
34
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
35
+ const body = answer.body;
36
+ const hits = body.hits ?? [];
37
+ writeLine(door.io.stderr, `${TAG} search ${JSON.stringify({ returned: hits.length, has_more: body.hasMore === true, coverage: body.coverage ?? null })}`);
38
+ if (door.json)
39
+ return emitAgentDoor(door, { ok: true, ...body });
40
+ if (hits.length === 0)
41
+ writeLine(door.io.stdout, "No messages match.");
42
+ for (const hit of hits) {
43
+ const where = hit.channel ? (hit.channel.is_dm ? "direct message" : `#${hit.channel.name ?? "unnamed"}`) : hit.channel_id;
44
+ writeLine(door.io.stdout, `${hit.id} ${where} ${hit.author_name ?? hit.user_id} ${hit.created_at} ${snippet(hit.content)}`);
45
+ }
46
+ writeLine(door.io.stdout, body.hasMore && body.nextCursor ? `more: --before ${body.nextCursor}` : "complete");
47
+ return 0;
48
+ }
@@ -13,7 +13,21 @@ const CONTENT_MAX_CHARS = 8_000;
13
13
  /** The one sentence a deleted message reads as, on every surface. */
14
14
  export const DELETED_SENTENCE = "This message was deleted";
15
15
  export function messageText(message) {
16
- return message.deleted_at ? `(${DELETED_SENTENCE})` : (message.content ?? "");
16
+ if (message.deleted_at)
17
+ return `(${DELETED_SENTENCE})`;
18
+ const names = message.files?.map((file) => file.name) ?? [];
19
+ if (message.content)
20
+ return names.length > 0 ? `${message.content} [files: ${names.join(", ")}]` : message.content;
21
+ // BLI-21521: an attachment-only post has NULL content and is not deleted.
22
+ const blockCount = Array.isArray(message.blocks)
23
+ ? Number(message.blocks.find((block) => block?.type === "files")?.count ?? 0)
24
+ : 0;
25
+ const count = names.length || blockCount;
26
+ if (count === 1 && names[0])
27
+ return `Shared a file: ${names[0]}`;
28
+ if (count === 1)
29
+ return "Shared a file";
30
+ return count > 1 ? `Shared ${count} files` : "";
17
31
  }
18
32
  export async function readContent(io) {
19
33
  if (isInteractiveStdin(io)) {
@@ -17,20 +17,24 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
20
- import { writeLine } from "./cli-io.js";
20
+ import { isInteractiveStdin, writeLine } from "./cli-io.js";
21
21
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
22
22
  import { markReadThrough } from "./msg-read-line.js";
23
+ import { runMsgActivityVerb } from "./msg-activity.js";
24
+ import { runMsgDraft } from "./msg-draft.js";
25
+ import { runMsgSearch } from "./msg-search.js";
26
+ import { openDm } from "./msg-dm.js";
27
+ import { runMsgMark } from "./msg-mark.js";
28
+ import { runMsgChannel } from "./msg-channel.js";
29
+ import { runMsgFile } from "./msg-file.js";
23
30
  import { deleteMessage, editMessage, messageOutcome, messageText, readContent, refuseMsgWrite } from "./msg-write.js";
24
31
  const TAG = "[msg cli]";
25
- const READ_DEADLINE_MS = 30_000;
26
- const WRITE_DEADLINE_MS = 30_000;
32
+ const [READ_DEADLINE_MS, WRITE_DEADLINE_MS] = [30_000, 30_000];
27
33
  export async function runMsg(command, io) {
28
34
  const door = await openAgentDoor("msg", command, io);
29
35
  switch (command.action) {
30
- case "show":
31
- return showMessage(command, door);
32
- case "channels":
33
- return listChannels(door);
36
+ case "show": return showMessage(command, door);
37
+ case "channels": return listChannels(door);
34
38
  case "read":
35
39
  return readChannel(command, door);
36
40
  case "unread":
@@ -43,14 +47,63 @@ export async function runMsg(command, io) {
43
47
  return deleteMessage(command, door);
44
48
  case "outcome":
45
49
  return messageOutcome(command, door);
50
+ case "draft":
51
+ return draftVerb(command, door);
52
+ case "file":
53
+ return fileVerb(command, door);
46
54
  case "thread":
47
55
  return readThread(command, door);
48
- case "create":
49
- return createChannel(command, door);
50
- case "dm":
51
- return openDm(command, door);
56
+ case "create": return createChannel(command, door);
57
+ case "dm": return openDm(command, door);
58
+ case "search":
59
+ return runMsgSearch(command, door);
60
+ case "channel":
61
+ case "members":
62
+ case "hide":
63
+ case "unhide":
64
+ return runMsgChannel(command, door, (ref) => resolveChannelId(door, ref));
65
+ case "save":
66
+ case "saved":
67
+ case "pin":
68
+ case "pins":
69
+ case "forward":
70
+ case "schedule":
71
+ case "remind":
72
+ case "scheduled":
73
+ case "context":
74
+ return runMsgMark(command, door, (ref) => resolveChannelId(door, ref));
75
+ case "activity":
76
+ case "follow":
77
+ case "unfollow":
78
+ case "react":
79
+ case "notify":
80
+ return runMsgActivityVerb(command, door, (ref) => resolveChannelId(door, ref));
52
81
  }
53
82
  }
83
+ /** `msg draft`: `list` needs no channel; every other verb resolves the channel the same way read and send do. */
84
+ async function draftVerb(command, door) {
85
+ if ((command.draftVerb ?? "list") === "list")
86
+ return runMsgDraft(command, door, null);
87
+ const ref = command.channelRef ?? "";
88
+ const resolved = await resolveChannelId(door, ref);
89
+ if (resolved.status === "list_failed")
90
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
91
+ if (resolved.status === "not_found")
92
+ return channelNotFound(door, ref);
93
+ return runMsgDraft(command, door, resolved.id);
94
+ }
95
+ /** `msg file`: `upload` and `list` name a channel; the other verbs name a file. */
96
+ async function fileVerb(command, door) {
97
+ if (command.fileVerb !== "upload" && command.fileVerb !== "list")
98
+ return runMsgFile(command, door, null);
99
+ const ref = command.channelRef ?? "";
100
+ const resolved = await resolveChannelId(door, ref);
101
+ if (resolved.status === "list_failed")
102
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
103
+ if (resolved.status === "not_found")
104
+ return channelNotFound(door, ref);
105
+ return runMsgFile(command, door, resolved.id);
106
+ }
54
107
  async function fetchChannels(door) {
55
108
  const answer = await readThroughCache({
56
109
  io: door.io,
@@ -261,10 +314,16 @@ async function sendMessage(command, door) {
261
314
  return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
262
315
  if (resolved.status === "not_found")
263
316
  return channelNotFound(door, ref);
264
- const content = await readContent(door.io);
265
- if (!content.ok)
266
- return failAgentDoor(door, TAG, content.reason, content.detail);
267
- if (content.text === "")
317
+ // BLI-21521: with --file the text is optional; stdin is read only when something is piped in.
318
+ const fileIds = command.fileIds ?? [];
319
+ let text = "";
320
+ if (fileIds.length === 0 || !isInteractiveStdin(door.io)) {
321
+ const content = await readContent(door.io);
322
+ if (!content.ok)
323
+ return failAgentDoor(door, TAG, content.reason, content.detail);
324
+ text = content.text;
325
+ }
326
+ if (text === "" && fileIds.length === 0)
268
327
  return failAgentDoor(door, TAG, "invalid_body", "There was nothing to send. Nothing was sent.");
269
328
  // BLI-21314: every send carries a request id, so a lost response can be checked and a retry posts once.
270
329
  const requestId = command.requestId ?? randomUUID();
@@ -274,7 +333,8 @@ async function sendMessage(command, door) {
274
333
  label: "msg send",
275
334
  timeoutMs: WRITE_DEADLINE_MS,
276
335
  body: {
277
- content: content.text,
336
+ ...(text === "" ? {} : { content: text }),
337
+ ...(fileIds.length > 0 ? { file_ids: fileIds } : {}),
278
338
  request_id: requestId,
279
339
  ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
280
340
  },
@@ -282,7 +342,7 @@ async function sendMessage(command, door) {
282
342
  if (!answer.ok)
283
343
  return refuseMsgWrite(door, answer, requestId, "send");
284
344
  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 })}`);
345
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, request_id: requestId, chars: text.length, files: fileIds.length })}`);
286
346
  if (door.json)
287
347
  return emitAgentDoor(door, { ok: true, message, mutation });
288
348
  writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
@@ -345,28 +405,6 @@ function reportChannelCreationToHuman(door, channel, name, requestedMembers, mem
345
405
  writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
346
406
  }
347
407
  }
348
- async function openDm(command, door) {
349
- const email = command.dmEmail ?? "";
350
- if (email === "")
351
- return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
352
- const answer = await askAgentDoor(door, {
353
- path: "/api/msg/channels",
354
- method: "POST",
355
- label: "msg dm",
356
- timeoutMs: WRITE_DEADLINE_MS,
357
- body: { dm_participant_emails: [email] },
358
- });
359
- if (!answer.ok)
360
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
361
- const channel = answer.body.channel ?? null;
362
- writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
363
- if (door.json)
364
- return emitAgentDoor(door, { ok: true, channel });
365
- // Idempotent by contract (`get_or_create_dm`), so the wording says what is
366
- // true either way rather than claiming a creation that may not have happened.
367
- writeLine(door.io.stdout, `Direct message with ${email}: ${channel?.id ?? "?"}`);
368
- return 0;
369
- }
370
408
  async function showMessage(command, door) {
371
409
  const answer = await askAgentDoor(door, { path: `/api/msg/messages/${encodeURIComponent(command.threadId ?? "")}`, method: "GET", label: "msg show", timeoutMs: READ_DEADLINE_MS });
372
410
  if (!answer.ok)
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `cockpit project` argument parsing (BLI-3716 list, BLI-21659 show/create/update/archive/restore).
3
+ *
4
+ * A description never travels on argv by default: `--file <path>` reads it from a local file (safest on Windows);
5
+ * `--description <text>` is the short form and `--description none` clears it. Write-safety flags
6
+ * (`--request-id`, `--expected-updated-at`) belong to the four write verbs.
7
+ */
8
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
9
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ const ACTIONS = ["list", "show", "create", "update", "archive", "restore"];
11
+ const WRITES = ["create", "update", "archive", "restore"];
12
+ export function parseProjectArgs(args) {
13
+ const values = parseNamedArgs(args, {
14
+ allowedFlags: [
15
+ "--home", "--dashboard-url", "--archived", "--cursor", "--limit", "--json",
16
+ "--name", "--description", "--file", "--color", "--request-id", "--expected-updated-at",
17
+ ],
18
+ valueFlags: [
19
+ "--home", "--dashboard-url", "--cursor", "--limit",
20
+ "--name", "--description", "--file", "--color", "--request-id", "--expected-updated-at",
21
+ ],
22
+ });
23
+ const first = values.positionals[0];
24
+ const action = (first === undefined ? "list" : first);
25
+ if (!ACTIONS.includes(action)) {
26
+ throw new Error(`Unknown project command: ${first}. Try list, show, create, update, archive or restore.`);
27
+ }
28
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
29
+ const needsRef = action !== "list" && action !== "create";
30
+ if (needsRef && rest.length !== 1)
31
+ throw new Error(`project ${action} takes exactly one project id or name.`);
32
+ if (!needsRef && rest.length > 0)
33
+ throw new Error(`project ${action} does not take "${rest[0]}".`);
34
+ const given = (flag) => values.flags.has(flag);
35
+ const isWrite = WRITES.includes(action);
36
+ if (!isWrite && (given("--request-id") || given("--expected-updated-at"))) {
37
+ throw new Error("--request-id and --expected-updated-at belong to create, update, archive and restore.");
38
+ }
39
+ if (action !== "create" && action !== "update" && (given("--name") || given("--description") || given("--file"))) {
40
+ throw new Error("--name, --description and --file belong to project create and update.");
41
+ }
42
+ if (action !== "create" && given("--color"))
43
+ throw new Error("--color belongs to project create.");
44
+ if (action !== "list" && (given("--cursor") || given("--limit") || values.booleans.has("--archived"))) {
45
+ throw new Error("--archived, --cursor and --limit belong to project list.");
46
+ }
47
+ if (given("--description") && given("--file"))
48
+ throw new Error("Give --description or --file, not both.");
49
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
50
+ if (requestId && !UUID_PATTERN.test(requestId))
51
+ throw new Error("--request-id must be a UUID.");
52
+ const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
53
+ if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
54
+ throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
55
+ }
56
+ const name = optionalNonEmpty(values.flags.get("--name"));
57
+ if (action === "create" && !name)
58
+ throw new Error('project create needs --name "<name>".');
59
+ const rawDescription = values.flags.get("--description");
60
+ const description = rawDescription === undefined ? undefined : rawDescription.trim().toLowerCase() === "none" ? null : rawDescription;
61
+ const filePath = optionalNonEmpty(values.flags.get("--file"));
62
+ if (action === "update" && name === undefined && description === undefined && filePath === undefined) {
63
+ throw new Error("project update needs --name, --description (or none) or --file.");
64
+ }
65
+ return {
66
+ kind: "project",
67
+ action,
68
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
69
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
70
+ includeArchived: values.booleans.has("--archived"),
71
+ ...(optionalNonEmpty(values.flags.get("--cursor")) ? { cursor: optionalNonEmpty(values.flags.get("--cursor")) } : {}),
72
+ ...(values.flags.has("--limit") ? { limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit") } : {}),
73
+ ...(needsRef ? { ref: rest[0] } : {}),
74
+ ...(name === undefined ? {} : { name }),
75
+ ...(description === undefined ? {} : { description }),
76
+ ...(filePath === undefined ? {} : { filePath }),
77
+ ...(optionalNonEmpty(values.flags.get("--color")) ? { color: optionalNonEmpty(values.flags.get("--color")) } : {}),
78
+ ...(requestId ? { requestId } : {}),
79
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
80
+ json: values.booleans.has("--json"),
81
+ };
82
+ }