@bli-cockpit/cli 0.2.240 → 0.2.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  * them, adds and edits them, ticks them off, links an action to the meeting it came from, and
6
6
  * decides the AI's suggestions, the same things the manager page does.
7
7
  *
8
- * Eighteen verbs (`home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
8
+ * Nineteen verbs (`home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
9
9
  * twins (`manager_*`) use; the door passes Railway's answer through unchanged. Every write on an
10
10
  * existing item carries `--version`, the item's `updatedAt` as `list`/`show` print it: a stale one is
11
11
  * refused with the door's own sentence and nothing is written. `approve`, `dismiss` and `proposal`
@@ -16,8 +16,11 @@ import { randomUUID } from "node:crypto";
16
16
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
17
17
  import { colorEnabled, writeLine } from "./cli-io.js";
18
18
  import { localCommandHelp } from "./local-help.js";
19
- import { clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
19
+ import { savedTextLines, clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
20
20
  import { homeLines, summaryWrittenLines } from "./manager-render-home.js";
21
+ import { parseManagerArgs } from "./local-args-tower-manager.js";
22
+ import { READ_ONLY_HINTS, RESEEN_REFUSALS, itemHints, summaryHints } from "./manager-hints.js";
23
+ import { readFieldsText } from "./manager-fields-file.js";
21
24
  import { planManagerRequest } from "./manager-requests.js";
22
25
  const TAG = "[manager cli]";
23
26
  /**
@@ -26,12 +29,38 @@ const TAG = "[manager cli]";
26
29
  * giving up first while the write may still land.
27
30
  */
28
31
  export const MANAGER_DEADLINE_MS = 65_000;
29
- const staleHint = (id, timeline) => `Run \`cockpit manager show ${id}${timeline ? " --timeline" : ""}\` for the current version and try again.`;
30
- /** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
31
32
  /** The verbs that change an existing item. */
32
- const WRITES = new Set(["summary", "edit", "done", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
33
- const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
34
- export async function runManager(command, io) {
33
+ const WRITES = new Set(["summary", "edit", "done", "drop", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
34
+ /**
35
+ * `--fields-file`: the parser left the fields unread. Read the file (or stdin), and parse the same args again with its text,
36
+ * so the file's values get exactly the flags' checks. Nothing has touched the network yet, so a bad file costs nothing.
37
+ */
38
+ async function withFieldsFile(command, io) {
39
+ if (command.fieldsFile === undefined || command.rawArgs === undefined)
40
+ return command;
41
+ const source = command.fieldsFile === "-" ? "stdin" : "file";
42
+ try {
43
+ const text = await readFieldsText(command.fieldsFile, io);
44
+ const parsed = parseManagerArgs(command.rawArgs, { fieldsText: text });
45
+ writeLine(io.stderr, `${TAG} fields read ${JSON.stringify({ source, bytes: Buffer.byteLength(text, "utf8"), verb: command.action })}`);
46
+ const { fieldsFile: _file, rawArgs: _args, ...rest } = parsed;
47
+ return { ...rest, homeDir: command.homeDir, dashboardUrl: command.dashboardUrl, json: command.json };
48
+ }
49
+ catch (error) {
50
+ const detail = error instanceof Error ? error.message : "the fields file could not be read";
51
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "fields_file_invalid", source, verb: command.action })}`);
52
+ if (command.json)
53
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: "fields_file_invalid", detail }));
54
+ else
55
+ writeLine(io.stderr, detail);
56
+ return 1;
57
+ }
58
+ }
59
+ export async function runManager(given, io) {
60
+ const loaded = await withFieldsFile(given, io);
61
+ if (typeof loaded === "number")
62
+ return loaded;
63
+ const command = loaded;
35
64
  if (command.help) {
36
65
  writeLine(io.stderr, `${TAG} help printed ${JSON.stringify({ reason: "no_verb_or_help_asked" })}`);
37
66
  writeLine(io.stdout, localCommandHelp("manager"));
@@ -107,12 +136,14 @@ function renderAnswer(command, body, color) {
107
136
  case "home": return homeLines(body, command.client ?? "<client>", color);
108
137
  case "summary": return summaryWrittenLines(body, color);
109
138
  case "run": return runLines(body);
110
- case "list": return listLines(body, timeline, color);
139
+ case "list": return listLines(body, timeline, color, new Date(), command.archived !== true && command.review !== "rejected", command.ownerFilter !== undefined || command.noOwner === true);
111
140
  case "queue": return queueLines(body, color);
112
141
  case "show": return itemDetailLines(body, timeline, color);
113
- default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color)];
142
+ default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color), ...(command.action === "add" ? savedTextLines(body) : [])];
114
143
  }
115
144
  }
145
+ /** An add answered `created: false`: the same request id came back, so the item that already existed is returned. */
146
+ const ALREADY_ADDED = "Already added earlier under this request id; nothing new was written.";
116
147
  /** What a write did, in one sentence a person reads; the item's new version follows on its row. */
117
148
  function writeSentence(command, body) {
118
149
  const as = "as you";
@@ -120,10 +151,14 @@ function writeSentence(command, body) {
120
151
  const waiting = body.item?.proposedChanges?.length ?? 0;
121
152
  const queue = waiting === 0 ? "It leaves the review queue." : `${waiting === 1 ? "Its other suggested change still waits" : `Its ${waiting} other suggested changes still wait`} in the review queue.`;
122
153
  switch (command.action) {
123
- case "add": return [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
124
- case "edit": return ["Saved your changes."];
154
+ // Only an explicit `created: false` is "not created": an older server sends no field, and that add did write.
155
+ case "add": return body.created === false ? [ALREADY_ADDED] : [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
156
+ // Railway writes nothing when every field already equals the stored one: no new version, no reviewed mark.
157
+ case "edit": return [body.unchanged === true ? "No change; nothing was written." : "Saved your changes."];
125
158
  // Railway writes nothing when the action already has that status, so its version comes back unchanged.
126
159
  case "done": return [body.item?.updatedAt === command.version ? "Already done; nothing changed." : "Marked done."];
160
+ // A dropped action was not done. Railway writes nothing when it is already dropped (`unchanged: true`).
161
+ case "drop": return [body.unchanged === true || body.item?.updatedAt === command.version ? "No change; nothing was written." : `Dropped: ${body.item?.title ?? "that action"}.`];
127
162
  case "reopen": return [body.item?.updatedAt === command.version ? "Already open; nothing changed." : "Reopened."];
128
163
  case "link": return [command.timelineItemId ? `Linked to ${body.timelineItem?.title ?? "that meeting"}.` : "Unlinked from its meeting."];
129
164
  case "approve": return [command.restore ? `Restored: ${body.item?.title ?? "that item"}, back in the lists and marked reviewed by you.` : `Approved ${as}. ${queue.replace("other ", "")}`];
@@ -170,7 +205,7 @@ function nextStep(reason, refusal, command) {
170
205
  if (reason === "not_found" && command?.id) {
171
206
  return command.timeline
172
207
  ? " Check the id with `cockpit manager list <client> --timeline`; for an action, drop --timeline."
173
- : ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "reopen" || command.action === "link" ? " (done, reopen and link are for actions only)" : ""}.`;
208
+ : ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "drop" || command.action === "reopen" || command.action === "link" ? " (done, drop, reopen and link are for actions only)" : ""}.`;
174
209
  }
175
210
  // A client-level command (home, summary, client, list, queue, add) the door cannot find the client of: "That client does not exist".
176
211
  if (reason === "not_found" && !command?.id && command?.action !== "run")
@@ -183,26 +218,30 @@ function nextStep(reason, refusal, command) {
183
218
  if ((reason === "manager_unreachable" || reason === "manager_timeout") && (command === undefined || !WRITES.has(command.action) && command.action !== "add")) {
184
219
  return " Try again in a minute; if it keeps failing, Tower's manager server may be down.";
185
220
  }
186
- switch (refusal) {
187
- // Another person's or another client's add already used this id: a fresh one makes the item.
188
- case "request_reused": return " Run the add again without --request-id to make a new item.";
189
- case "proposal_replaced": return ` Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`;
190
- case "item_archived": return ` Bring it back first: \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`;
191
- case "item_dismissed": return ` Restore it first: \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`;
192
- case "person_item_not_dismissable": return ` To delete an item a person added, use \`cockpit manager archive ${id}${flag} --version <v>\`.`;
193
- case "timeline_not_live": return " Link to a live timeline item of the same client: `cockpit manager list <client> --timeline` lists them.";
194
- case "item_not_archived": return " It is already in the lists; nothing to bring back.";
195
- default: return "";
196
- }
221
+ // The door's own 403 for a read-only key names itself as the reason.
222
+ const readOnly = READ_ONLY_HINTS[reason];
223
+ if (readOnly)
224
+ return readOnly;
225
+ const hint = refusal ? itemHints(id, flag)[refusal] : undefined;
226
+ return hint ? ` ${hint}` : "";
197
227
  }
198
228
  function refuse(door, answer, command, requestId) {
199
229
  const body = answer.body && typeof answer.body === "object" ? answer.body : {};
200
230
  const text = (value) => (typeof value === "string" && value.trim() !== "" ? value : undefined);
201
- const sentence = text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
202
231
  const refusal = text(body["refusal"]);
232
+ // `--version none` says "this client has no card yet": a card that exists is the only way the version check can refuse it, and
233
+ // the server's own sentence ("Someone changed this card after you opened it") would send the person looking for a change nobody made.
234
+ const cardExists = refusal === "summary_changed" && command?.action === "summary" && command.version === null;
235
+ // An older Tower server's status door takes only done and open: its 400 for "dropped" is a schema message, not a way forward.
236
+ const noDrop = command?.action === "drop" && answer.httpStatus === 400 && refusal === undefined;
237
+ const sentence = noDrop
238
+ ? "This Tower server can't drop actions yet; mark it done with a dated line saying why, or ask for a server update."
239
+ : cardExists
240
+ ? `This client already has a card: pass its version (\`cockpit manager home ${clientWord(command)}\` shows it).`
241
+ : text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
203
242
  const reseen = answer.reason === "invalid_state" && (refusal === undefined || RESEEN_REFUSALS.has(refusal));
204
- const hint = reseen && command?.id
205
- ? ` ${staleHint(command.id, command.timeline)}`
243
+ const hint = cardExists || noDrop ? "" : reseen && command?.id
244
+ ? ` ${itemHints(command.id, command.timeline ? " --timeline" : "").item_changed}`
206
245
  : nextStep(answer.reason, refusal, command);
207
246
  // An add with no answer from Tower (or a server error) may or may not have been made: the same request id makes the retry safe.
208
247
  const unconfirmed = requestId !== undefined && (answer.httpStatus === undefined || answer.httpStatus >= 500);
@@ -222,24 +261,9 @@ function refuse(door, answer, command, requestId) {
222
261
  return failAgentDoor(door, TAG, answer.reason, `${sentence}${stop}${hint}${retry}`, { ...(refusal ? { refusal } : {}), ...(unconfirmed ? { requestId } : {}) });
223
262
  }
224
263
  const clientWord = (command) => (command.client && /\s/.test(command.client) ? `"${command.client}"` : command.client ?? "<client>");
225
- /** The way forward after each named refusal of a "Where we are" edit (`TOWER_MANAGER_SUMMARY_REFUSALS`). */
264
+ /** The way forward after each named refusal of a "Where we are" edit (`TOWER_MANAGER_SUMMARY_REFUSALS`, `manager-hints.ts`). */
226
265
  function summaryHint(refusal, command) {
227
266
  const home = `\`cockpit manager home ${clientWord(command)} --json\``;
228
- const hints = {
229
- summary_changed: `Someone or the check wrote the card since: read it again with ${home} and retry with its summary.updatedAt as --version (none only when it has no card).`,
230
- summary_empty: "Give at least one of --health, --headline, --bullet, --next, --blocker.",
231
- summary_incomplete: "This client has no card yet: give --health, --headline and at least one --bullet together (with --version none).",
232
- summary_health_invalid: "--health is on_track, at_risk or blocked.",
233
- summary_headline_empty: "Give the headline some words.",
234
- summary_headline_too_long: "Keep --headline to 140 characters.",
235
- summary_bullets_count: "Give 1 to 5 --bullet flags; together they replace the whole list.",
236
- summary_bullet_empty: "Every --bullet needs words (after its date, when it has one).",
237
- summary_bullet_too_long: "Keep each --bullet to 160 characters.",
238
- summary_bullet_date_invalid: "Start a dated bullet with a real day, \"YYYY-MM-DD: \", or leave the date out.",
239
- summary_next_too_long: "Keep --next to 200 characters.",
240
- summary_blocker_too_long: "Keep --blocker to 200 characters.",
241
- summary_blocked_needs_blocker: "blocked needs a blocker: add --blocker <what blocks it>, or pick another --health.",
242
- };
243
- const hint = hints[refusal];
267
+ const hint = summaryHints(home)[refusal];
244
268
  return hint ? ` ${hint}` : ` Read the card with ${home} and try again.`;
245
269
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.240");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.241");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.240",
3
+ "version": "0.2.241",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.70",
31
- "@bli-cockpit/mcp": "0.1.145",
31
+ "@bli-cockpit/mcp": "0.1.146",
32
32
  "@bli-cockpit/telemetry-core": "0.1.77"
33
33
  },
34
34
  "repository": {