@bli-cockpit/cli 0.2.242 → 0.2.244

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 (135) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +6 -50
  4. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  5. package/dist/agent-rules.js +8 -0
  6. package/dist/autostart-contract.js +12 -0
  7. package/dist/autostart-darwin-plist.js +2 -2
  8. package/dist/autostart-darwin.js +1 -1
  9. package/dist/autostart-remind.js +245 -0
  10. package/dist/autostart-self-heal.js +17 -1
  11. package/dist/autostart-windows-scripts.js +7 -7
  12. package/dist/autostart.js +16 -15
  13. package/dist/commands/approvals-check-where.js +14 -0
  14. package/dist/commands/approvals-render.js +8 -1
  15. package/dist/commands/approvals.js +3 -2
  16. package/dist/commands/autostart-command.js +10 -0
  17. package/dist/commands/autostart-heal.js +36 -1
  18. package/dist/commands/brain.js +1 -1
  19. package/dist/commands/cal-agenda.js +44 -0
  20. package/dist/commands/cal-availability-share.js +97 -0
  21. package/dist/commands/cal-booking.js +147 -0
  22. package/dist/commands/cal-join.js +62 -0
  23. package/dist/commands/cal-links.js +68 -0
  24. package/dist/commands/cal-people.js +136 -0
  25. package/dist/commands/cal-remind.js +176 -0
  26. package/dist/commands/cal-reminders.js +33 -0
  27. package/dist/commands/cal-suggest.js +89 -0
  28. package/dist/commands/cal.js +41 -1
  29. package/dist/commands/cycle-args.js +127 -0
  30. package/dist/commands/cycles.js +117 -0
  31. package/dist/commands/docs-comments-args.js +85 -0
  32. package/dist/commands/docs-comments.js +128 -0
  33. package/dist/commands/docs-files-args.js +49 -0
  34. package/dist/commands/docs-files.js +179 -0
  35. package/dist/commands/docs-formatting.js +45 -0
  36. package/dist/commands/docs-inbox-args.js +93 -0
  37. package/dist/commands/docs-inbox.js +126 -0
  38. package/dist/commands/docs-notion.js +5 -0
  39. package/dist/commands/docs-read.js +2 -1
  40. package/dist/commands/docs-recovery.js +173 -0
  41. package/dist/commands/docs-refs-args.js +104 -0
  42. package/dist/commands/docs-refs.js +153 -0
  43. package/dist/commands/docs-search-args.js +79 -0
  44. package/dist/commands/docs-search.js +64 -0
  45. package/dist/commands/docs-sync-args.js +82 -0
  46. package/dist/commands/docs-sync.js +197 -0
  47. package/dist/commands/docs-tree.js +136 -0
  48. package/dist/commands/docs.js +85 -51
  49. package/dist/commands/doctor-update.js +4 -1
  50. package/dist/commands/inbox-args.js +81 -0
  51. package/dist/commands/inbox.js +88 -0
  52. package/dist/commands/issue-batch.js +61 -0
  53. package/dist/commands/issue-relations.js +151 -0
  54. package/dist/commands/issue-write.js +38 -5
  55. package/dist/commands/issue.js +32 -1
  56. package/dist/commands/local-args-collector-sources.js +2 -1
  57. package/dist/commands/local-args-tower-approvals.js +16 -4
  58. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  59. package/dist/commands/local-args-tower-cal-links.js +42 -0
  60. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  61. package/dist/commands/local-args-tower-cal-share.js +41 -0
  62. package/dist/commands/local-args-tower-cal.js +81 -8
  63. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  64. package/dist/commands/local-args-tower-msg.js +222 -10
  65. package/dist/commands/local-args-tower-search.js +32 -5
  66. package/dist/commands/local-args-tower-work.js +110 -34
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +6 -4
  69. package/dist/commands/local-help-commands-tower.js +47 -31
  70. package/dist/commands/local-help-commands.js +3 -1
  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 +3 -3
  84. package/dist/commands/manager-hints.js +4 -2
  85. package/dist/commands/manager-limits.json +3 -1
  86. package/dist/commands/manager-read-reasons.js +1 -0
  87. package/dist/commands/manager-render-home.js +13 -5
  88. package/dist/commands/manager-render.js +3 -0
  89. package/dist/commands/manager.js +3 -1
  90. package/dist/commands/mcp-stdio-probe.js +1 -0
  91. package/dist/commands/msg-activity.js +134 -0
  92. package/dist/commands/msg-channel-args.js +92 -0
  93. package/dist/commands/msg-channel.js +125 -0
  94. package/dist/commands/msg-context-args.js +67 -0
  95. package/dist/commands/msg-context.js +65 -0
  96. package/dist/commands/msg-dm.js +28 -0
  97. package/dist/commands/msg-file.js +185 -0
  98. package/dist/commands/msg-mark-args.js +78 -0
  99. package/dist/commands/msg-mark.js +148 -0
  100. package/dist/commands/msg-notify-args.js +100 -0
  101. package/dist/commands/msg-notify.js +122 -0
  102. package/dist/commands/msg-schedule-args.js +114 -0
  103. package/dist/commands/msg-schedule.js +226 -0
  104. package/dist/commands/msg-search-args.js +71 -0
  105. package/dist/commands/msg-search.js +48 -0
  106. package/dist/commands/msg-write.js +15 -1
  107. package/dist/commands/msg.js +62 -39
  108. package/dist/commands/project-args.js +82 -0
  109. package/dist/commands/project.js +71 -7
  110. package/dist/commands/public-root.js +1 -1
  111. package/dist/commands/search.js +23 -0
  112. package/dist/commands/session-sync-failures.js +17 -4
  113. package/dist/commands/session-sync-health.js +7 -2
  114. package/dist/commands/session-sync-hold.js +42 -1
  115. package/dist/commands/session-sync-outside-roots.js +72 -14
  116. package/dist/commands/session-sync-upload.js +2 -0
  117. package/dist/commands/session-sync.js +1 -0
  118. package/dist/commands/sources-check.js +63 -2
  119. package/dist/commands/sources-discovery.js +1 -1
  120. package/dist/commands/sources-reconciliation.js +87 -0
  121. package/dist/commands/sync-doctor.js +14 -1
  122. package/dist/commands/sync-report.js +1 -1
  123. package/dist/commands/template-args.js +122 -0
  124. package/dist/commands/templates.js +162 -0
  125. package/dist/commands/view-args.js +131 -0
  126. package/dist/commands/views.js +138 -0
  127. package/dist/commands/work-notify-args.js +67 -0
  128. package/dist/commands/work-notify.js +63 -0
  129. package/dist/delivery-hold-notice.js +34 -0
  130. package/dist/evidence-transcripts-client.js +1 -0
  131. package/dist/log-rotation.js +2 -2
  132. package/dist/notifier.js +83 -0
  133. package/dist/process-runner.js +4 -0
  134. package/dist/repo-identity-git.js +1 -0
  135. package/package.json +4 -4
@@ -83,13 +83,14 @@ export function withTodayLine(rows, dates, today) {
83
83
  const line = [todayLine(today)];
84
84
  return at === -1 ? [...rows, line] : [...rows.slice(0, at), line, ...rows.slice(at)];
85
85
  }
86
- /** Who wrote the card, by its `source`: the credit's opening words (the day follows; a person's name comes after "Edited by"). */
86
+ /** Who wrote the card, by its `source`: the credit's opening words (the day follows; a person's name comes after "Edited by" and "Written by", then "'s agent" for the agent's card). */
87
87
  export const SUMMARY_SOURCE_WORDS = {
88
88
  model: "Written by AI",
89
89
  computed: "AI unavailable · computed from counts",
90
90
  person: "Edited by",
91
+ agent: "Written by",
91
92
  };
92
- /** Who wrote the card and when: "written by AI", "computed from counts", or "edited by" a person. */
93
+ /** Who wrote the card and when: "written by AI", "computed from counts", "edited by" a person, or "written by" a person's agent. */
93
94
  function authorLine(summary, now) {
94
95
  // The server's own Vancouver day when it sends one; never worked out here when it did.
95
96
  const at = summary.writtenOn ? `${dayWords(summary.writtenOn)} (${ageText(summary.writtenAt, now)})` : when(summary.writtenAt, now);
@@ -98,6 +99,8 @@ function authorLine(summary, now) {
98
99
  if (summary.source === "computed")
99
100
  return `${SUMMARY_SOURCE_WORDS.computed}, ${at}.`;
100
101
  // Never a user id in a person's terminal: the door fills the name, or says "a teammate".
102
+ if (summary.source === "agent")
103
+ return `${SUMMARY_SOURCE_WORDS.agent} ${summary.editedByName ?? "a teammate"}'s agent, ${at}.`;
101
104
  return `${SUMMARY_SOURCE_WORDS.person} ${summary.editedByName ?? "a teammate"}, ${at}.`;
102
105
  }
103
106
  const CAP_WORDS = {
@@ -169,8 +172,11 @@ function summaryLines(home, now, color) {
169
172
  if (checked !== null && Number.isFinite(new Date(checked).getTime()))
170
173
  lines.push(` Last checked ${ageText(checked, now)}.`);
171
174
  }
172
- if (summary.inputsChanged && !summary.lastCheckFailed)
173
- lines.push(" Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue (for an AI-written card, also a meeting or milestone came within a week or passed) after this was written; the next check writes it again.");
175
+ if (summary.inputsChanged && !summary.lastCheckFailed) {
176
+ lines.push(summary.source === "agent"
177
+ ? " Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue after this was written; Tower's check leaves the agent's card, so the agent or a person writes it again."
178
+ : " Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue (for an AI-written card, also a meeting or milestone came within a week or passed) after this was written; the next check writes it again.");
179
+ }
174
180
  lines.push(counts, dim(` version ${summary.updatedAt} (what \`summary --version\` takes)`, color));
175
181
  return lines;
176
182
  }
@@ -378,7 +384,9 @@ export function summaryWrittenLines(summary, color, now = new Date()) {
378
384
  lines.push(` Next: ${summary.next}`);
379
385
  if (summary.blocker)
380
386
  lines.push(` Blocker: ${summary.blocker}`);
381
- lines.push(` ${authorLine(summary, now)} It stands until the client's data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes it again.`);
387
+ lines.push(summary.source === "agent"
388
+ ? ` ${authorLine(summary, now)} Tower's check never rewrites it; your agent's next edit, or a person's on the page, replaces it.`
389
+ : ` ${authorLine(summary, now)} It stands until the client's data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes it again.`);
382
390
  lines.push(dim(` version ${summary.updatedAt}`, color));
383
391
  return lines;
384
392
  }
@@ -149,6 +149,9 @@ export function handledByLines(item, timeline) {
149
149
  const human = item.origin === "human";
150
150
  if (human && item.createdBy)
151
151
  lines.push(`Added by ${person(item.createdByName)}${onDay(item.createdAt)}.`);
152
+ // An AI item with a creator is a person's agent's add through the door (BLI-21947); the model's own have none.
153
+ if (!human && item.createdBy)
154
+ lines.push(`Added by ${person(item.createdByName)}'s agent${onDay(item.createdAt)}${item.reviewStatus === "unreviewed" ? "; not checked yet" : ""}.`);
152
155
  if (item.reviewStatus === "rejected" && item.reviewedBy)
153
156
  lines.push(`Dismissed by ${person(item.reviewedByName)}${onDay(item.reviewedAt)}.`);
154
157
  else if (item.reviewedBy && item.reviewStatus === "reviewed") {
@@ -144,6 +144,8 @@ function renderAnswer(command, body, color) {
144
144
  }
145
145
  /** An add answered `created: false`: the same request id came back, so the item that already existed is returned. */
146
146
  const ALREADY_ADDED = "Already added earlier under this request id; nothing new was written.";
147
+ /** An add the server stored as the caller's agent's, unchecked (BLI-21947); an older server's reviewed add says nothing more. */
148
+ const addedUnchecked = (body) => body.item?.reviewStatus === "unreviewed" ? " Stored as added by your agent, not yet checked: a person approves or dismisses it." : "";
147
149
  /** What a write did, in one sentence a person reads; the item's new version follows on its row. */
148
150
  function writeSentence(command, body) {
149
151
  const as = "as you";
@@ -152,7 +154,7 @@ function writeSentence(command, body) {
152
154
  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.`;
153
155
  switch (command.action) {
154
156
  // 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."];
157
+ 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."}${addedUnchecked(body)}`];
156
158
  // Railway writes nothing when every field already equals the stored one: no new version, no reviewed mark.
157
159
  case "edit": return [body.unchanged === true ? "No change; nothing was written." : "Saved your changes."];
158
160
  // Railway writes nothing when the action already has that status, so its version comes back unchanged.
@@ -260,6 +260,7 @@ function defaultSpawn(command, args, options) {
260
260
  return spawn(command, args, {
261
261
  env: options.env,
262
262
  stdio: ["pipe", "pipe", "ignore"],
263
+ windowsHide: true,
263
264
  });
264
265
  }
265
266
  function defaultRealpath(value) {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The activity verbs of `cockpit msg` (BLI-21598): `activity`, `follow`, `unfollow` and `react`. Same doors
3
+ * the browser calls (`/api/msg/activity`, `/messages/<id>/follow`, `/messages/<id>/reactions`); `--json`
4
+ * prints the door's body unchanged so ids, rows, coverage and reasons match HTTP. Split out of `msg.ts` for the file-size ratchet.
5
+ */
6
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
7
+ import { writeLine } from "./cli-io.js";
8
+ import { runMsgNotify } from "./msg-notify.js";
9
+ const TAG = "[msg cli]";
10
+ const READ_DEADLINE_MS = 30_000;
11
+ const WRITE_DEADLINE_MS = 30_000;
12
+ /** One line for a body: the first line of text, trimmed to a readable length. */
13
+ function preview(content) {
14
+ const line = (content ?? "").split("\n")[0] ?? "";
15
+ return line.length > 140 ? `${line.slice(0, 137)}...` : line;
16
+ }
17
+ function countsLine(counts) {
18
+ if (!counts)
19
+ return "Unread counts are unavailable right now.";
20
+ const part = (label, count) => `${label} ${count.unread}${count.capped ? "+" : ""}`;
21
+ return `Unread: ${part("mentions", counts.mention)}, ${part("threads", counts.reply)}.`;
22
+ }
23
+ /** `cockpit msg activity [--mentions|--threads] [--unread] [--before <cursor>] [--limit <n>] [--mark-read]` */
24
+ export async function runMsgActivity(command, door) {
25
+ const query = new URLSearchParams();
26
+ if (command.activityKind)
27
+ query.set("kind", command.activityKind);
28
+ if (command.unreadOnly)
29
+ query.set("unread", "1");
30
+ if (command.before)
31
+ query.set("before", command.before);
32
+ if (command.limit)
33
+ query.set("limit", String(command.limit));
34
+ const answer = await askAgentDoor(door, {
35
+ path: `/api/msg/activity${query.size > 0 ? `?${query.toString()}` : ""}`,
36
+ method: "GET",
37
+ label: "msg activity",
38
+ timeoutMs: READ_DEADLINE_MS,
39
+ });
40
+ if (!answer.ok)
41
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
42
+ const body = answer.body;
43
+ const items = body.items ?? [];
44
+ writeLine(door.io.stderr, `${TAG} activity ${JSON.stringify({ returned: items.length, dropped: body.coverage?.dropped ?? 0, has_more: body.hasMore === true })}`);
45
+ // `--mark-read` is the person asking to be told these now: it marks exactly the unread items just printed.
46
+ let marked = null;
47
+ const unreadIds = items.filter((item) => item.read_at === null).map((item) => item.message_id);
48
+ if (command.markRead && unreadIds.length > 0) {
49
+ const read = await askAgentDoor(door, {
50
+ path: "/api/msg/activity/read",
51
+ method: "POST",
52
+ label: "msg activity read",
53
+ timeoutMs: WRITE_DEADLINE_MS,
54
+ body: { message_ids: unreadIds },
55
+ });
56
+ if (!read.ok)
57
+ return failAgentDoor(door, TAG, read.reason, `${read.detail} The items above were NOT marked read.`);
58
+ marked = read.body;
59
+ }
60
+ if (door.json)
61
+ return emitAgentDoor(door, { ok: true, ...body, ...(command.markRead ? { markRead: marked ?? { requested: 0, marked: 0 } } : {}) });
62
+ writeLine(door.io.stdout, countsLine(body.counts));
63
+ if (items.length === 0) {
64
+ writeLine(door.io.stdout, command.unreadOnly ? "Nothing unread." : "No activity yet.");
65
+ }
66
+ for (const item of items) {
67
+ const channel = body.channels?.[item.channel_id];
68
+ const where = channel ? (channel.is_dm ? "a direct message" : `#${channel.name ?? "unnamed"}`) : item.channel_id;
69
+ const who = (item.message.user_id ? body.displayNameById?.[item.message.user_id] : null) ?? item.message.agent_label ?? item.message.slack_author_label ?? "Someone";
70
+ writeLine(door.io.stdout, `${item.message_id} ${item.created_at} ${item.kind}${item.read_at === null ? " (unread)" : ""} ${where} ${who} ${preview(item.message.content)}`);
71
+ }
72
+ if ((body.coverage?.dropped ?? 0) > 0)
73
+ writeLine(door.io.stdout, `${body.coverage?.dropped} item(s) no longer readable were left out.`);
74
+ if (body.nextCursor)
75
+ writeLine(door.io.stdout, `More: --before ${body.nextCursor}`);
76
+ if (marked)
77
+ writeLine(door.io.stdout, `Marked ${marked.marked} read.`);
78
+ return 0;
79
+ }
80
+ /** `cockpit msg follow|unfollow <message-id>` — the id is the thread's top-level message. */
81
+ export async function runMsgFollow(command, door) {
82
+ const following = command.action === "follow";
83
+ const answer = await askAgentDoor(door, {
84
+ path: `/api/msg/messages/${encodeURIComponent(command.targetId ?? "")}/follow`,
85
+ method: following ? "PUT" : "DELETE",
86
+ label: `msg ${command.action}`,
87
+ timeoutMs: WRITE_DEADLINE_MS,
88
+ });
89
+ if (!answer.ok)
90
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
91
+ const follow = answer.body.follow ?? {};
92
+ writeLine(door.io.stderr, `${TAG} ${command.action} ${JSON.stringify({ message_id: command.targetId, following: follow.following === true, changed: follow.changed === true })}`);
93
+ if (door.json)
94
+ return emitAgentDoor(door, { ok: true, ...answer.body });
95
+ const state = follow.following ? "Following" : "Not following";
96
+ writeLine(door.io.stdout, `${state} thread ${command.targetId}${follow.reason ? ` (${follow.reason})` : ""}${follow.changed === false ? ": it was already that way." : "."}`);
97
+ return 0;
98
+ }
99
+ /** `cockpit msg react <message-id> <emoji> [--remove]` */
100
+ export async function runMsgReact(command, door) {
101
+ const emoji = command.emoji ?? "";
102
+ const id = encodeURIComponent(command.targetId ?? "");
103
+ const answer = command.remove
104
+ ? await askAgentDoor(door, {
105
+ path: `/api/msg/messages/${id}/reactions?emoji=${encodeURIComponent(emoji)}`,
106
+ method: "DELETE",
107
+ label: "msg react remove",
108
+ timeoutMs: WRITE_DEADLINE_MS,
109
+ })
110
+ : await askAgentDoor(door, {
111
+ path: `/api/msg/messages/${id}/reactions`,
112
+ method: "POST",
113
+ label: "msg react",
114
+ timeoutMs: WRITE_DEADLINE_MS,
115
+ body: { emoji },
116
+ });
117
+ if (!answer.ok)
118
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
119
+ writeLine(door.io.stderr, `${TAG} react ${JSON.stringify({ message_id: command.targetId, removed: command.remove === true })}`);
120
+ if (door.json)
121
+ return emitAgentDoor(door, { ok: true, removed: command.remove === true, ...(answer.body ?? {}) });
122
+ writeLine(door.io.stdout, command.remove ? `Removed ${emoji} from ${command.targetId}.` : `Reacted ${emoji} on ${command.targetId}.`);
123
+ return 0;
124
+ }
125
+ /** The one entry `runMsg` calls for activity, follow, unfollow, react and notify (BLI-21605; `resolve` turns a channel name into its id). */
126
+ export function runMsgActivityVerb(command, door, resolve) {
127
+ if (command.action === "notify")
128
+ return runMsgNotify(command, door, resolve);
129
+ if (command.action === "activity")
130
+ return runMsgActivity(command, door);
131
+ if (command.action === "react")
132
+ return runMsgReact(command, door);
133
+ return runMsgFollow(command, door);
134
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Arguments of `cockpit msg channel|members|hide|unhide` (BLI-21685): `channel <channel>` shows details, with
3
+ * `--rename <name> | --describe <text> | --archive | --unarchive` it changes them; `members <channel>` lists, with
4
+ * `--add <email> | --remove <email>` it changes membership; `hide <channel>` / `unhide <channel>` change only your own
5
+ * sidebar. Split out of `local-args-tower-msg.ts`.
6
+ *
7
+ * `--remove` is a switch for `msg react`, and an email here. `members` is spotted in the raw words and its `--remove` is
8
+ * renamed before the shared parser sees it (`msgChannelNormalizeArgs`).
9
+ */
10
+ import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
11
+ export const MSG_CHANNEL_ACTIONS = ["channel", "members", "hide", "unhide"];
12
+ export const MSG_CHANNEL_VALUE_FLAGS = ["--rename", "--describe", "--add", "--remove-member"];
13
+ export const MSG_CHANNEL_BOOLEAN_FLAGS = ["--archive", "--unarchive"];
14
+ const MSG_CHANNEL_ONLY_FLAGS = [...MSG_CHANNEL_VALUE_FLAGS, ...MSG_CHANNEL_BOOLEAN_FLAGS];
15
+ export function isMsgChannelAction(action) {
16
+ return MSG_CHANNEL_ACTIONS.includes(action);
17
+ }
18
+ /** For `msg members ... --remove <email>`: the shared parser sees `--remove-member`, so `msg react --remove` is untouched. */
19
+ export function msgChannelNormalizeArgs(args) {
20
+ const action = args.find((arg) => !arg.startsWith("--") && (isMsgChannelAction(arg) || ["channels", "read", "send", "react", "thread", "create", "dm", "show"].includes(arg)));
21
+ if (action !== "members")
22
+ return args;
23
+ return args.map((arg) => (arg === "--remove" ? "--remove-member" : arg.startsWith("--remove=") ? `--remove-member=${arg.slice(9)}` : arg));
24
+ }
25
+ /** A flag of these verbs used on another msg verb is named, not ignored. */
26
+ export function rejectMsgChannelFlags(values) {
27
+ const stray = MSG_CHANNEL_ONLY_FLAGS.find((flag) => values.flags.has(flag) || values.booleans.has(flag));
28
+ if (stray)
29
+ throw new Error(`${stray === "--remove-member" ? "--remove" : stray} does not belong here; it is for \`cockpit msg channel\` or \`cockpit msg members\`.`);
30
+ }
31
+ function emailOf(raw, flag) {
32
+ const email = optionalNonEmpty(raw) ?? "";
33
+ if (!email.includes("@"))
34
+ throw new Error(`${flag} takes an email address; "${email}" is not one.`);
35
+ return email;
36
+ }
37
+ export function parseMsgChannelArgs(action, values, rest) {
38
+ const has = (flag) => values.flags.has(flag) || values.booleans.has(flag);
39
+ const stray = ["--thread", "--channel", "--members", "--description", "--from", "--request-id", "--expected-revision", "--name", "--out", "--file", "--mark-read", "--mentions", "--threads", "--unread", "--private", "--before", "--limit", "--level", "--timezone", "--message", "--mute", "--unmute", "--clear", "--off"].find(has);
40
+ if (stray)
41
+ throw new Error(`${stray} does not belong to \`cockpit msg ${action}\`.`);
42
+ if (values.booleans.has("--remove"))
43
+ throw new Error("--remove belongs to `cockpit msg react`; for members use `cockpit msg members <channel> --remove <email>`.");
44
+ const channelRef = optionalNonEmpty(rest[0]);
45
+ if (!channelRef)
46
+ throw new Error(`msg ${action} needs a channel id or name, e.g. \`cockpit msg ${action} general\`.`);
47
+ if (rest.length > 1)
48
+ throw new Error(`msg ${action} takes one channel, not ${rest.length}.`);
49
+ const rename = values.flags.get("--rename");
50
+ const describe = values.flags.get("--describe");
51
+ const archive = values.booleans.has("--archive");
52
+ const unarchive = values.booleans.has("--unarchive");
53
+ const add = values.flags.get("--add");
54
+ const remove = values.flags.get("--remove-member");
55
+ const base = {
56
+ kind: "msg",
57
+ action,
58
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
59
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
60
+ isPrivate: false,
61
+ channelRef,
62
+ json: values.booleans.has("--json"),
63
+ };
64
+ if (action === "channel") {
65
+ if (add || remove)
66
+ throw new Error("--add and --remove belong to `cockpit msg members`.");
67
+ if (archive && unarchive)
68
+ throw new Error("Pick one of --archive or --unarchive.");
69
+ const changes = [];
70
+ if (rename !== undefined)
71
+ changes.push({ kind: "rename", name: rename.replace(/^#/, "") });
72
+ if (describe !== undefined)
73
+ changes.push({ kind: "describe", text: describe });
74
+ if (archive)
75
+ changes.push({ kind: "archive" });
76
+ if (unarchive)
77
+ changes.push({ kind: "unarchive" });
78
+ return { ...base, channelChanges: changes };
79
+ }
80
+ if (rename !== undefined || describe !== undefined || archive || unarchive) {
81
+ throw new Error("--rename, --describe, --archive and --unarchive belong to `cockpit msg channel`.");
82
+ }
83
+ if (action === "members") {
84
+ if (add && remove)
85
+ throw new Error("Pick one of --add or --remove.");
86
+ const memberChange = add ? { kind: "add", email: emailOf(add, "--add") } : remove ? { kind: "remove", email: emailOf(remove, "--remove") } : undefined;
87
+ return { ...base, ...(memberChange ? { memberChange } : {}) };
88
+ }
89
+ if (add || remove)
90
+ throw new Error(`--add and --remove belong to \`cockpit msg members\`, not \`msg ${action}\`.`);
91
+ return base;
92
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `cockpit msg channel|members|hide|unhide` (BLI-21685): a channel's details and its changes, its people, and your own
3
+ * sidebar. Same doors the browser calls (`/api/msg/channels/<id>`, `.../members`, `.../hidden`); `--json` prints the
4
+ * door's body unchanged so ids, audience and the manage answer match HTTP. A refusal keeps the door's reason:
5
+ * channel_manage_refused, channel_name_taken, dm_not_editable, hide_not_public, person_not_found.
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 audienceLine(details) {
12
+ if (details.audience === "org")
13
+ return "Audience: everyone in the org can read it";
14
+ if (details.audience === "participants")
15
+ return `Audience: ${details.member_count ?? 0} people in this conversation`;
16
+ return `Audience: ${details.member_count ?? 0} members`;
17
+ }
18
+ function printDetails(door, details) {
19
+ const out = door.io.stdout;
20
+ writeLine(out, `${details.is_dm ? "Direct message" : `#${details.name ?? ""}`} (${details.id})`);
21
+ writeLine(out, `Kind: ${details.is_dm ? "direct message" : details.is_private ? "private channel" : "public channel"}${details.archived_at ? " (archived)" : ""}`);
22
+ writeLine(out, `Description: ${details.description ?? "none"}`);
23
+ writeLine(out, audienceLine(details));
24
+ if (details.source_workspace)
25
+ writeLine(out, `Imported from the ${details.source_workspace} Slack workspace (a label; it grants no access)`);
26
+ writeLine(out, `You can manage it: ${details.can_manage ? "yes" : "no"}`);
27
+ if (!details.is_dm && !details.is_private)
28
+ writeLine(out, `Hidden from your sidebar: ${details.hidden ? "yes" : "no"}`);
29
+ }
30
+ async function resolveOrFail(command, door, resolve) {
31
+ const ref = command.channelRef ?? "";
32
+ const resolved = await resolve(ref);
33
+ if (resolved.status === "list_failed")
34
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
35
+ if (resolved.status === "not_found") {
36
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
37
+ }
38
+ return resolved.id;
39
+ }
40
+ async function channelVerb(command, door, id) {
41
+ const path = `/api/msg/channels/${encodeURIComponent(id)}`;
42
+ const changes = command.channelChanges ?? [];
43
+ if (changes.length === 0) {
44
+ const answer = await askAgentDoor(door, { path, method: "GET", label: "msg channel", timeoutMs: DEADLINE_MS });
45
+ if (!answer.ok)
46
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
47
+ const details = answer.body.channel;
48
+ writeLine(door.io.stderr, `${TAG} channel details ${JSON.stringify({ channel_id: id, audience: details.audience, can_manage: details.can_manage })}`);
49
+ if (door.json)
50
+ return emitAgentDoor(door, { ok: true, ...answer.body });
51
+ printDetails(door, details);
52
+ return 0;
53
+ }
54
+ const body = {};
55
+ for (const change of changes) {
56
+ if (change.kind === "rename")
57
+ body["name"] = change.name;
58
+ if (change.kind === "describe")
59
+ body["description"] = change.text;
60
+ if (change.kind === "archive")
61
+ body["archived"] = true;
62
+ if (change.kind === "unarchive")
63
+ body["archived"] = false;
64
+ }
65
+ const answer = await askAgentDoor(door, { path, method: "PATCH", label: "msg channel update", timeoutMs: DEADLINE_MS, body });
66
+ if (!answer.ok)
67
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
68
+ writeLine(door.io.stderr, `${TAG} channel updated ${JSON.stringify({ channel_id: id, fields: Object.keys(body) })}`);
69
+ if (door.json)
70
+ return emitAgentDoor(door, { ok: true, ...answer.body });
71
+ printDetails(door, answer.body.channel);
72
+ return 0;
73
+ }
74
+ async function membersVerb(command, door, id) {
75
+ const path = `/api/msg/channels/${encodeURIComponent(id)}/members`;
76
+ const change = command.memberChange;
77
+ if (change) {
78
+ const answer = change.kind === "add"
79
+ ? await askAgentDoor(door, { path, method: "POST", label: "msg members add", timeoutMs: DEADLINE_MS, body: { email: change.email } })
80
+ : await askAgentDoor(door, { path: `${path}?email=${encodeURIComponent(change.email)}`, method: "DELETE", label: "msg members remove", timeoutMs: DEADLINE_MS });
81
+ if (!answer.ok)
82
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
83
+ writeLine(door.io.stderr, `${TAG} members ${change.kind} ${JSON.stringify({ channel_id: id })}`);
84
+ if (door.json)
85
+ return emitAgentDoor(door, { ok: true, channel_id: id, change: change.kind, email: change.email });
86
+ writeLine(door.io.stdout, change.kind === "add" ? `Added ${change.email}.` : `Removed ${change.email}. Their next read of a private channel is refused.`);
87
+ return 0;
88
+ }
89
+ const answer = await askAgentDoor(door, { path, method: "GET", label: "msg members", timeoutMs: DEADLINE_MS });
90
+ if (!answer.ok)
91
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
92
+ const body = answer.body;
93
+ const members = body.members ?? [];
94
+ writeLine(door.io.stderr, `${TAG} members listed ${JSON.stringify({ channel_id: id, count: members.length, audience: body.audience ?? null })}`);
95
+ if (door.json)
96
+ return emitAgentDoor(door, { ok: true, ...body });
97
+ if (body.audience === "org")
98
+ writeLine(door.io.stdout, "Everyone in the org can read this channel.");
99
+ for (const member of members)
100
+ writeLine(door.io.stdout, `${member.user_id} ${member.role.padEnd(14)} ${body.display_names?.[member.user_id] ?? "someone"}`);
101
+ if (members.length === 0 && body.audience !== "org")
102
+ writeLine(door.io.stdout, "No members.");
103
+ return 0;
104
+ }
105
+ async function hideVerb(command, door, id) {
106
+ const hide = command.action === "hide";
107
+ const answer = await askAgentDoor(door, { path: `/api/msg/channels/${encodeURIComponent(id)}/hidden`, method: hide ? "PUT" : "DELETE", label: hide ? "msg hide" : "msg unhide", timeoutMs: DEADLINE_MS });
108
+ if (!answer.ok)
109
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
110
+ writeLine(door.io.stderr, `${TAG} ${command.action} ${JSON.stringify({ channel_id: id })}`);
111
+ if (door.json)
112
+ return emitAgentDoor(door, { ok: true, ...answer.body });
113
+ writeLine(door.io.stdout, hide ? "Hidden from your sidebar. Everyone can still read, search and link to it." : "Shown in your sidebar again.");
114
+ return 0;
115
+ }
116
+ export async function runMsgChannel(command, door, resolve) {
117
+ const id = await resolveOrFail(command, door, resolve);
118
+ if (typeof id === "number")
119
+ return id;
120
+ if (command.action === "channel")
121
+ return channelVerb(command, door, id);
122
+ if (command.action === "members")
123
+ return membersVerb(command, door, id);
124
+ return hideVerb(command, door, id);
125
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Arguments of `cockpit msg context <message-id> [--add <kind:id>] [--remove <link-id>] [--json]` (BLI-21829).
3
+ * `--add doc:<id>|ticket:<BLI-id or uuid>|event:<id>[@<iso occurrence>]|note:<id>`; `--remove` takes the message id first and the
4
+ * link id after it. Split out of `local-args-tower-msg.ts`.
5
+ */
6
+ import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
7
+ export const MSG_CONTEXT_VALUE_FLAGS = ["--add"];
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9]{1,12}-\d+$/;
10
+ const KINDS = ["doc", "ticket", "event", "note"];
11
+ /** `--add` used on another msg verb is named, not ignored. */
12
+ export function rejectMsgContextFlags(values) {
13
+ if (values.flags.has("--add"))
14
+ throw new Error("--add belongs to `cockpit msg context`.");
15
+ }
16
+ export function parseContextSpec(spec) {
17
+ const match = /^(doc|ticket|event|note):(.+)$/i.exec(spec.trim());
18
+ if (!match)
19
+ throw new Error("--add takes doc:<id>, ticket:<BLI-id or uuid>, event:<id>[@<iso occurrence>] or note:<id>.");
20
+ const kind = match[1].toLowerCase();
21
+ const [id = "", occurrence] = match[2].split("@", 2);
22
+ const ok = kind === "ticket" ? UUID.test(id) || IDENTIFIER.test(id) : UUID.test(id);
23
+ if (!ok)
24
+ throw new Error(`--add ${kind}: needs ${kind === "ticket" ? "a BLI id or a uuid" : "a uuid"}, not "${id}".`);
25
+ if (occurrence !== undefined) {
26
+ if (kind !== "event")
27
+ throw new Error("An @<iso occurrence> belongs to event: only.");
28
+ const time = Date.parse(occurrence);
29
+ if (Number.isNaN(time))
30
+ throw new Error(`--add event: the occurrence "${occurrence}" is not an ISO time.`);
31
+ return { kind, id, occurrence: new Date(time).toISOString() };
32
+ }
33
+ return { kind, id };
34
+ }
35
+ export function parseMsgContextArgs(values, rest) {
36
+ const stray = ["--channel", "--members", "--description", "--from", "--expected-revision", "--name", "--out", "--file", "--level", "--timezone", "--message", "--to", "--before", "--limit", "--thread", "--request-id"]
37
+ .find((flag) => values.flags.has(flag));
38
+ if (stray || values.booleans.has("--private"))
39
+ throw new Error(`${stray ?? "--private"} does not belong to \`cockpit msg context\`.`);
40
+ const targetId = optionalNonEmpty(rest[0]);
41
+ if (!targetId || !UUID.test(targetId)) {
42
+ throw new Error("msg context needs a message id (a UUID), e.g. `cockpit msg context <message-id> --json`.");
43
+ }
44
+ const add = optionalNonEmpty(values.flags.get("--add"));
45
+ const removing = values.booleans.has("--remove");
46
+ if (add && removing)
47
+ throw new Error("msg context takes --add or --remove, not both.");
48
+ const base = {
49
+ kind: "msg",
50
+ action: "context",
51
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
52
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
53
+ isPrivate: false,
54
+ json: values.booleans.has("--json"),
55
+ targetId,
56
+ };
57
+ if (removing) {
58
+ const linkId = optionalNonEmpty(rest[1]);
59
+ if (!linkId || !UUID.test(linkId) || rest.length > 2) {
60
+ throw new Error("msg context --remove takes the message id and then the link id, e.g. `cockpit msg context <message-id> --remove <link-id>`.");
61
+ }
62
+ return { ...base, contextRemove: linkId };
63
+ }
64
+ if (rest.length > 1)
65
+ throw new Error(`msg context takes one message id, not ${rest.length}.`);
66
+ return { ...base, ...(add ? { contextAdd: parseContextSpec(add) } : {}) };
67
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `cockpit msg context` (BLI-21829): the Tower docs, tickets, calendar events and meeting notes pointed at from one message.
3
+ * Same doors the browser calls (`/api/msg/messages/<id>/context`, `.../context/<link-id>`); `--json` prints the door's body
4
+ * unchanged so link ids, kinds and `state: "unavailable"` match HTTP. Every target is read again for YOU: one you cannot open
5
+ * shows as "Not available to you" with no title. A refusal keeps the door's reason: context_target_unavailable,
6
+ * context_not_author, context_already_linked, context_limit_reached, context_link_not_found.
7
+ */
8
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
9
+ import { writeLine } from "./cli-io.js";
10
+ const TAG = "[msg cli]";
11
+ const DEADLINE_MS = 30_000;
12
+ function line(link) {
13
+ if (link.state === "unavailable")
14
+ return `${link.link_id} ${link.kind} Not available to you`;
15
+ const label = link.kind === "ticket" && link.identifier ? `${link.identifier} ${link.title ?? ""}`.trim() : (link.title ?? link.id);
16
+ return `${link.link_id} ${link.kind} ${label}${link.href ? ` ${link.href}` : ""}`;
17
+ }
18
+ export async function runMsgContext(command, door) {
19
+ const messageId = encodeURIComponent(command.targetId ?? "");
20
+ if (command.contextRemove) {
21
+ const answer = await askAgentDoor(door, {
22
+ path: `/api/msg/messages/${messageId}/context/${encodeURIComponent(command.contextRemove)}`,
23
+ method: "DELETE",
24
+ label: "msg context remove",
25
+ timeoutMs: DEADLINE_MS,
26
+ });
27
+ if (!answer.ok)
28
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
29
+ writeLine(door.io.stderr, `${TAG} context removed ${JSON.stringify({ message_id: command.targetId, link_id: command.contextRemove })}`);
30
+ if (door.json)
31
+ return emitAgentDoor(door, { ok: true, ...answer.body });
32
+ writeLine(door.io.stdout, `Removed context ${command.contextRemove} from ${command.targetId}.`);
33
+ return 0;
34
+ }
35
+ if (command.contextAdd) {
36
+ const add = command.contextAdd;
37
+ const answer = await askAgentDoor(door, {
38
+ path: `/api/msg/messages/${messageId}/context`,
39
+ method: "POST",
40
+ body: { target_kind: add.kind, target_id: add.id, ...(add.occurrence ? { occurrence_start: add.occurrence } : {}) },
41
+ label: "msg context add",
42
+ timeoutMs: DEADLINE_MS,
43
+ });
44
+ if (!answer.ok)
45
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
46
+ const link = answer.body.link;
47
+ writeLine(door.io.stderr, `${TAG} context added ${JSON.stringify({ message_id: command.targetId, kind: add.kind, link_id: link?.link_id ?? null })}`);
48
+ if (door.json)
49
+ return emitAgentDoor(door, { ok: true, ...answer.body });
50
+ writeLine(door.io.stdout, `Added ${add.kind} context to ${command.targetId}${link ? `:\n${line(link)}` : "."}`);
51
+ return 0;
52
+ }
53
+ const answer = await askAgentDoor(door, { path: `/api/msg/messages/${messageId}/context`, method: "GET", label: "msg context", timeoutMs: DEADLINE_MS });
54
+ if (!answer.ok)
55
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
56
+ const links = answer.body.links ?? [];
57
+ writeLine(door.io.stderr, `${TAG} context ${JSON.stringify({ message_id: command.targetId, links: links.length, unavailable: links.filter((link) => link.state === "unavailable").length })}`);
58
+ if (door.json)
59
+ return emitAgentDoor(door, { ok: true, ...answer.body });
60
+ for (const link of links)
61
+ writeLine(door.io.stdout, line(link));
62
+ if (links.length === 0)
63
+ writeLine(door.io.stdout, "No Tower context on this message.");
64
+ return 0;
65
+ }
@@ -0,0 +1,28 @@
1
+ /** `cockpit msg dm <email>`: lookup-or-create a direct message (BLI-3749). Split out of `msg.ts` for the file-size ratchet. */
2
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
3
+ import { writeLine } from "./cli-io.js";
4
+ const TAG = "[msg cli]";
5
+ const WRITE_DEADLINE_MS = 30_000;
6
+ export async function openDm(command, door) {
7
+ const emails = command.dmEmails ?? (command.dmEmail ? [command.dmEmail] : []);
8
+ const email = emails.join(", ");
9
+ if (emails.length === 0)
10
+ return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
11
+ const answer = await askAgentDoor(door, {
12
+ path: "/api/msg/channels",
13
+ method: "POST",
14
+ label: "msg dm",
15
+ timeoutMs: WRITE_DEADLINE_MS,
16
+ body: { dm_participant_emails: emails },
17
+ });
18
+ if (!answer.ok)
19
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
20
+ const channel = answer.body.channel ?? null;
21
+ writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
22
+ if (door.json)
23
+ return emitAgentDoor(door, { ok: true, channel });
24
+ // Idempotent by contract (`get_or_create_dm`), so the wording says what is
25
+ // true either way rather than claiming a creation that may not have happened.
26
+ writeLine(door.io.stdout, `${emails.length > 1 ? "Group message" : "Direct message"} with ${email}: ${channel?.id ?? "?"}`);
27
+ return 0;
28
+ }