@bli-cockpit/cli 0.2.242 → 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 (121) 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 +222 -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-auth.js +1 -1
  66. package/dist/commands/local-help-commands-tower-ops.js +2 -0
  67. package/dist/commands/local-help-commands-tower.js +47 -31
  68. package/dist/commands/local-help-cycles.js +9 -0
  69. package/dist/commands/local-help-docs-files.js +10 -0
  70. package/dist/commands/local-help-docs-formatting.js +7 -0
  71. package/dist/commands/local-help-docs-inbox.js +10 -0
  72. package/dist/commands/local-help-docs-recovery.js +10 -0
  73. package/dist/commands/local-help-docs-refs.js +11 -0
  74. package/dist/commands/local-help-docs-sync.js +9 -0
  75. package/dist/commands/local-help-inbox.js +9 -0
  76. package/dist/commands/local-help-project.js +21 -0
  77. package/dist/commands/local-help-templates.js +9 -0
  78. package/dist/commands/local-help-views.js +10 -0
  79. package/dist/commands/local-help-work-notify.js +6 -0
  80. package/dist/commands/local-help.js +2 -2
  81. package/dist/commands/mcp-stdio-probe.js +1 -0
  82. package/dist/commands/msg-activity.js +134 -0
  83. package/dist/commands/msg-channel-args.js +92 -0
  84. package/dist/commands/msg-channel.js +125 -0
  85. package/dist/commands/msg-context-args.js +67 -0
  86. package/dist/commands/msg-context.js +65 -0
  87. package/dist/commands/msg-dm.js +28 -0
  88. package/dist/commands/msg-file.js +185 -0
  89. package/dist/commands/msg-mark-args.js +78 -0
  90. package/dist/commands/msg-mark.js +148 -0
  91. package/dist/commands/msg-notify-args.js +100 -0
  92. package/dist/commands/msg-notify.js +122 -0
  93. package/dist/commands/msg-schedule-args.js +114 -0
  94. package/dist/commands/msg-schedule.js +226 -0
  95. package/dist/commands/msg-search-args.js +71 -0
  96. package/dist/commands/msg-search.js +48 -0
  97. package/dist/commands/msg-write.js +15 -1
  98. package/dist/commands/msg.js +62 -39
  99. package/dist/commands/project-args.js +82 -0
  100. package/dist/commands/project.js +71 -7
  101. package/dist/commands/public-root.js +1 -1
  102. package/dist/commands/search.js +23 -0
  103. package/dist/commands/session-sync-failures.js +17 -4
  104. package/dist/commands/session-sync-health.js +7 -2
  105. package/dist/commands/session-sync-hold.js +42 -1
  106. package/dist/commands/session-sync-upload.js +2 -0
  107. package/dist/commands/session-sync.js +1 -0
  108. package/dist/commands/sync-doctor.js +14 -1
  109. package/dist/commands/sync-report.js +1 -1
  110. package/dist/commands/template-args.js +122 -0
  111. package/dist/commands/templates.js +162 -0
  112. package/dist/commands/view-args.js +131 -0
  113. package/dist/commands/views.js +138 -0
  114. package/dist/commands/work-notify-args.js +67 -0
  115. package/dist/commands/work-notify.js +63 -0
  116. package/dist/delivery-hold-notice.js +34 -0
  117. package/dist/log-rotation.js +2 -2
  118. package/dist/notifier.js +83 -0
  119. package/dist/process-runner.js +4 -0
  120. package/dist/repo-identity-git.js +1 -0
  121. package/package.json +4 -4
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cockpit msg notify` (BLI-21605): your message notification settings, quiet hours, channel overrides and delivery
3
+ * receipts. Same doors the browser calls (`/api/msg/notifications/settings`, `/api/msg/channels/<id>/notifications`,
4
+ * `/api/msg/notifications/receipts`); `--json` prints the door's body unchanged so ids, levels and reasons match HTTP.
5
+ * A phone receipt says `push_accepted` (the push service took it), never that a phone showed it.
6
+ */
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ const TAG = "[msg cli]";
10
+ const DEADLINE_MS = 30_000;
11
+ function settingsLines(settings) {
12
+ const quiet = settings.quiet_hours;
13
+ const lines = [
14
+ `Default level: ${settings.default_level}`,
15
+ quiet ? `Quiet hours: ${quiet.start}-${quiet.end} (${quiet.timezone})` : "Quiet hours: off",
16
+ ];
17
+ if (settings.channels.length === 0)
18
+ lines.push("No channel overrides.");
19
+ for (const channel of settings.channels) {
20
+ lines.push(`Channel ${channel.channel_id}: ${channel.muted ? "muted" : (channel.level ?? "default level")}${channel.muted && channel.level ? ` (level ${channel.level})` : ""}`);
21
+ }
22
+ return lines;
23
+ }
24
+ async function showSettings(door) {
25
+ const answer = await askAgentDoor(door, { path: "/api/msg/notifications/settings", method: "GET", label: "msg notify", timeoutMs: DEADLINE_MS });
26
+ if (!answer.ok)
27
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
28
+ const settings = answer.body.settings;
29
+ writeLine(door.io.stderr, `${TAG} notify settings ${JSON.stringify({ channels: settings.channels.length, quiet_hours: settings.quiet_hours !== null })}`);
30
+ if (door.json)
31
+ return emitAgentDoor(door, { ok: true, ...answer.body });
32
+ for (const line of settingsLines(settings))
33
+ writeLine(door.io.stdout, line);
34
+ return 0;
35
+ }
36
+ async function putSettings(door, label, body, done) {
37
+ const answer = await askAgentDoor(door, { path: "/api/msg/notifications/settings", method: "PUT", label, timeoutMs: DEADLINE_MS, body });
38
+ if (!answer.ok)
39
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
40
+ const result = answer.body;
41
+ writeLine(door.io.stderr, `${TAG} ${label} ${JSON.stringify({ changed: result.changed === true, fields: result.changedFields ?? [] })}`);
42
+ if (door.json)
43
+ return emitAgentDoor(door, { ok: true, ...answer.body });
44
+ writeLine(door.io.stdout, result.changed === false ? `${done} (it was already that way).` : `${done}.`);
45
+ return 0;
46
+ }
47
+ async function setChannel(command, door, resolve) {
48
+ const ref = command.channelRef ?? "";
49
+ const resolved = await resolve(ref);
50
+ if (resolved.status === "list_failed")
51
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
52
+ if (resolved.status === "not_found") {
53
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
54
+ }
55
+ const path = `/api/msg/channels/${encodeURIComponent(resolved.id)}/notifications`;
56
+ const body = {
57
+ ...(command.notifyLevel ? { level: command.notifyLevel } : {}),
58
+ ...(command.mute ? { muted: true } : {}),
59
+ ...(command.unmute ? { muted: false } : {}),
60
+ };
61
+ const answer = command.clear
62
+ ? await askAgentDoor(door, { path, method: "DELETE", label: "msg notify clear", timeoutMs: DEADLINE_MS })
63
+ : await askAgentDoor(door, { path, method: "PUT", label: "msg notify set", timeoutMs: DEADLINE_MS, body });
64
+ if (!answer.ok)
65
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
66
+ const result = answer.body;
67
+ writeLine(door.io.stderr, `${TAG} notify channel ${JSON.stringify({ channel_id: resolved.id, changed: result.changed === true, cleared: command.clear === true })}`);
68
+ if (door.json)
69
+ return emitAgentDoor(door, { ok: true, channel_id: resolved.id, ...answer.body });
70
+ const now = result.channel ? (result.channel.muted ? "muted" : (result.channel.level ?? "your default level")) : "your default level";
71
+ writeLine(door.io.stdout, `Channel ${resolved.id}: ${now}${result.changed === false ? " (it was already that way)" : ""}.`);
72
+ return 0;
73
+ }
74
+ async function showReceipts(command, door) {
75
+ const query = new URLSearchParams();
76
+ if (command.targetId)
77
+ query.set("message_id", command.targetId);
78
+ if (command.before)
79
+ query.set("before", command.before);
80
+ if (command.limit)
81
+ query.set("limit", String(command.limit));
82
+ const answer = await askAgentDoor(door, {
83
+ path: `/api/msg/notifications/receipts${query.size > 0 ? `?${query.toString()}` : ""}`,
84
+ method: "GET",
85
+ label: "msg notify receipts",
86
+ timeoutMs: DEADLINE_MS,
87
+ });
88
+ if (!answer.ok)
89
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
90
+ const body = answer.body;
91
+ const receipts = body.receipts ?? [];
92
+ writeLine(door.io.stderr, `${TAG} notify receipts ${JSON.stringify({ returned: receipts.length, has_more: body.hasMore === true })}`);
93
+ if (door.json)
94
+ return emitAgentDoor(door, { ok: true, ...body });
95
+ if (receipts.length === 0)
96
+ writeLine(door.io.stdout, "No delivery receipts.");
97
+ for (const receipt of receipts) {
98
+ writeLine(door.io.stdout, `${receipt.created_at} ${receipt.transport} ${receipt.kind} ${receipt.outcome} ${receipt.reason} message ${receipt.message_id}`);
99
+ }
100
+ if (body.nextCursor)
101
+ writeLine(door.io.stdout, `More: --before ${body.nextCursor}`);
102
+ return 0;
103
+ }
104
+ export async function runMsgNotify(command, door, resolve) {
105
+ switch (command.notifyVerb ?? "show") {
106
+ case "show":
107
+ return showSettings(door);
108
+ case "set":
109
+ if (command.channelRef)
110
+ return setChannel(command, door, resolve);
111
+ return putSettings(door, "msg notify set", { default_level: command.notifyLevel }, `Default level is ${command.notifyLevel}`);
112
+ case "quiet": {
113
+ if (command.quietOff)
114
+ return putSettings(door, "msg notify quiet", { quiet_hours: null }, "Quiet hours are off");
115
+ const [start, end] = (command.quietWindow ?? "").split("-");
116
+ const zone = command.timezone ?? "UTC";
117
+ return putSettings(door, "msg notify quiet", { quiet_hours: { start, end, timezone: zone } }, `Quiet hours are ${start}-${end} (${zone})`);
118
+ }
119
+ case "receipts":
120
+ return showReceipts(command, door);
121
+ }
122
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Arguments of `cockpit msg schedule|remind|scheduled` (BLI-21802):
3
+ * schedule <channel> --at <instant|"YYYY-MM-DD HH:MM"> [--timezone <zone>] [--thread <id>] [--request-id <uuid>] (body on stdin)
4
+ * remind <message-id> --at <...> [--timezone <zone>] [--note <text>] [--request-id <uuid>]
5
+ * scheduled [--state <state|open|all>] [--before <cursor>] [--limit <n>]
6
+ * scheduled change <id> [--at <...>] [--timezone <zone>] [--revision <n>] (optional new body on stdin)
7
+ * scheduled cancel <id> | scheduled done <id>
8
+ * Split out of `local-args-tower-msg.ts`.
9
+ */
10
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
11
+ export const MSG_SCHEDULE_ACTIONS = ["schedule", "remind", "scheduled"];
12
+ export const MSG_SCHEDULE_VALUE_FLAGS = ["--at", "--note", "--state", "--revision"];
13
+ export const SCHEDULE_STATE_NAMES = ["pending", "sent", "failed", "cancelled", "fired", "done", "open", "all"];
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const NOTE_MAX = 500;
16
+ export function isMsgScheduleAction(action) {
17
+ return MSG_SCHEDULE_ACTIONS.includes(action);
18
+ }
19
+ /** A schedule flag used on another msg verb is named, not ignored. */
20
+ export function rejectMsgScheduleFlags(values) {
21
+ const flag = MSG_SCHEDULE_VALUE_FLAGS.find((name) => values.flags.has(name));
22
+ if (flag)
23
+ throw new Error(`${flag} belongs to \`cockpit msg schedule\`, \`msg remind\` or \`msg scheduled\`.`);
24
+ }
25
+ export function parseMsgScheduleArgs(action, values, rest) {
26
+ const stray = ["--channel", "--members", "--description", "--from", "--expected-revision", "--name", "--out", "--file", "--level", "--message", "--to"]
27
+ .find((flag) => values.flags.has(flag));
28
+ const strayBoolean = ["--private", "--mentions", "--threads", "--unread", "--mark-read", "--remove"].find((flag) => values.booleans.has(flag));
29
+ if (stray || strayBoolean)
30
+ throw new Error(`${stray ?? strayBoolean} does not belong to \`cockpit msg ${action}\`.`);
31
+ const base = {
32
+ kind: "msg",
33
+ action,
34
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
35
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
36
+ isPrivate: false,
37
+ json: values.booleans.has("--json"),
38
+ };
39
+ const at = optionalNonEmpty(values.flags.get("--at"));
40
+ const timezone = optionalNonEmpty(values.flags.get("--timezone"));
41
+ const thread = optionalNonEmpty(values.flags.get("--thread"));
42
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
43
+ const note = values.flags.get("--note");
44
+ const state = optionalNonEmpty(values.flags.get("--state"));
45
+ const before = optionalNonEmpty(values.flags.get("--before"));
46
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
47
+ const revisionText = optionalNonEmpty(values.flags.get("--revision"));
48
+ const revision = revisionText === undefined ? undefined : optionalPositiveInteger(revisionText, "--revision");
49
+ if (requestId && !UUID.test(requestId))
50
+ throw new Error("--request-id takes a UUID.");
51
+ if (thread && (action !== "schedule" || !UUID.test(thread))) {
52
+ throw new Error(action === "schedule" ? "--thread takes a message id (a UUID)." : "--thread belongs to `cockpit msg schedule`.");
53
+ }
54
+ if (note !== undefined && action !== "remind")
55
+ throw new Error("--note belongs to `cockpit msg remind`.");
56
+ if (note !== undefined && note.length > NOTE_MAX)
57
+ throw new Error(`--note is limited to ${NOTE_MAX} characters.`);
58
+ if (requestId && action === "scheduled")
59
+ throw new Error("--request-id belongs to `cockpit msg schedule` and `msg remind`.");
60
+ if (action === "scheduled") {
61
+ const verb = rest[0];
62
+ if (verb === "change" || verb === "cancel" || verb === "done") {
63
+ const targetId = optionalNonEmpty(rest[1]);
64
+ if (!targetId || !UUID.test(targetId))
65
+ throw new Error(`msg scheduled ${verb} needs a schedule id (a UUID) from \`cockpit msg scheduled\`.`);
66
+ if (rest.length > 2)
67
+ throw new Error(`msg scheduled ${verb} takes one schedule id, not ${rest.length - 1}.`);
68
+ if (state || before || limit !== undefined)
69
+ throw new Error("--state, --before and --limit belong to `cockpit msg scheduled` (the list).");
70
+ if (verb !== "change" && (at || timezone || revision !== undefined))
71
+ throw new Error(`--at, --timezone and --revision belong to \`cockpit msg scheduled change\`.`);
72
+ return {
73
+ ...base, scheduleVerb: verb, targetId,
74
+ ...(at ? { scheduleAt: at } : {}), ...(timezone ? { timezone } : {}), ...(revision === undefined ? {} : { scheduleRevision: revision }),
75
+ };
76
+ }
77
+ if (rest.length > 0)
78
+ throw new Error(`msg scheduled does not take "${rest[0]}". Try change, cancel or done, or no word to list.`);
79
+ if (at || timezone || revision !== undefined)
80
+ throw new Error("--at, --timezone and --revision belong to `cockpit msg scheduled change`.");
81
+ if (state && !SCHEDULE_STATE_NAMES.includes(state)) {
82
+ throw new Error(`--state takes one of ${SCHEDULE_STATE_NAMES.join(", ")}.`);
83
+ }
84
+ return {
85
+ ...base, scheduleVerb: "list",
86
+ ...(state ? { scheduleState: state } : {}), ...(before ? { before } : {}), ...(limit === undefined ? {} : { limit }),
87
+ };
88
+ }
89
+ if (state || before || limit !== undefined || revision !== undefined) {
90
+ throw new Error(`--state, --before, --limit and --revision belong to \`cockpit msg scheduled\`, not \`msg ${action}\`.`);
91
+ }
92
+ const target = optionalNonEmpty(rest[0]);
93
+ if (rest.length > 1)
94
+ throw new Error(`msg ${action} takes one ${action === "schedule" ? "channel" : "message id"}, not ${rest.length}.`);
95
+ if (!at) {
96
+ throw new Error(action === "schedule"
97
+ ? 'msg schedule needs --at, e.g. `echo "standup notes" | cockpit msg schedule ops --at "2026-11-02 09:00" --timezone America/Toronto`.'
98
+ : 'msg remind needs --at, e.g. `cockpit msg remind <message-id> --at "2026-11-02 09:00" --timezone America/Toronto`.');
99
+ }
100
+ if (action === "schedule") {
101
+ if (!target)
102
+ throw new Error("msg schedule needs a channel id or name, e.g. `cockpit msg schedule ops --at ...`.");
103
+ return {
104
+ ...base, channelRef: target, scheduleAt: at,
105
+ ...(timezone ? { timezone } : {}), ...(thread ? { threadId: thread } : {}), ...(requestId ? { requestId } : {}),
106
+ };
107
+ }
108
+ if (!target || !UUID.test(target))
109
+ throw new Error("msg remind needs a message id (a UUID), e.g. `cockpit msg remind <message-id> --at ...`.");
110
+ return {
111
+ ...base, targetId: target, scheduleAt: at,
112
+ ...(timezone ? { timezone } : {}), ...(note !== undefined ? { scheduleNote: note } : {}), ...(requestId ? { requestId } : {}),
113
+ };
114
+ }
@@ -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)) {