@bli-cockpit/cli 0.2.239 → 0.2.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/commands/cal-delete.js +13 -3
  2. package/dist/commands/cal-scope.js +71 -0
  3. package/dist/commands/cal-view-settings.js +61 -0
  4. package/dist/commands/cal.js +11 -0
  5. package/dist/commands/docs-share.js +81 -0
  6. package/dist/commands/docs.js +5 -0
  7. package/dist/commands/drills-render.js +2 -2
  8. package/dist/commands/drills.js +4 -4
  9. package/dist/commands/issue-list-page.js +101 -0
  10. package/dist/commands/issue.js +23 -5
  11. package/dist/commands/local-arg-values.js +90 -19
  12. package/dist/commands/local-args-tower-cal.js +148 -8
  13. package/dist/commands/local-args-tower-docs-msg.js +30 -3
  14. package/dist/commands/local-args-tower-drills.js +1 -1
  15. package/dist/commands/local-args-tower-manager.js +128 -28
  16. package/dist/commands/local-args-tower-msg.js +43 -6
  17. package/dist/commands/local-args-tower-work.js +74 -6
  18. package/dist/commands/local-help-commands-tower-ops.js +35 -21
  19. package/dist/commands/local-help-commands-tower.js +15 -11
  20. package/dist/commands/local-help.js +1 -1
  21. package/dist/commands/manager-fields-file.js +208 -0
  22. package/dist/commands/manager-hints.js +88 -0
  23. package/dist/commands/manager-limits.json +269 -0
  24. package/dist/commands/manager-read-reasons.js +78 -0
  25. package/dist/commands/manager-render-home.js +213 -37
  26. package/dist/commands/manager-render.js +119 -21
  27. package/dist/commands/manager-requests.js +7 -3
  28. package/dist/commands/manager.js +67 -43
  29. package/dist/commands/msg-write.js +118 -0
  30. package/dist/commands/msg.js +20 -29
  31. package/dist/commands/project.js +15 -3
  32. package/dist/commands/public-root.js +1 -1
  33. package/package.json +2 -2
@@ -197,10 +197,10 @@ export const TOWER_OPS_COMMAND_HELP = [
197
197
  " cockpit drills start open the next drill: the change or command, what it printed, and the question. The cause is NOT shown",
198
198
  " cockpit drills answer --confidence <1-5> your answer on stdin (or --text), and how sure you are; then the real cause and the guard are shown",
199
199
  " cockpit drills theme your theme and the choices",
200
- " cockpit drills theme set <theme> [--play <t>] [--watch <t>] [--train <t>] video-game (the default), boxing, muay-thai, crime-drama, anime, sports, or your own words",
200
+ " cockpit drills theme set <theme> [--play <t>] [--watch <t>] [--train <t>] plain (the default: no theme), video-game, sports, movies-tv, music, anime, cooking, or anything you're into",
201
201
  " cockpit drills review your own week: hits, wins, highs, lows, where you have stopped improving, what to practise",
202
- "A drill replays a real mistake the team already paid for. You answer first, so a wrong answer you were sure about is the one you remember; it comes back 1, 3 and 7 days later.",
203
- "Each of your top patterns is a boss: pass its drills, then a stretch of your own PRs without taking that hit again. There is no leaderboard; these are your own rows only.",
202
+ "A drill replays a real mistake the team already paid for, shown straight with no theme unless you pick one. You answer first, so a wrong answer you were sure about is the one you remember; it comes back 1, 3 and 7 days later.",
203
+ "Each of your top patterns is something to beat (a boss, if you pick a theme): pass its drills, then a stretch of your own PRs without taking that hit again. There is no leaderboard; these are your own rows only.",
204
204
  "Anything that needs your hit history says so when it is not connected yet, instead of showing a zero.",
205
205
  "--home <dir> and --dashboard-url <url> are for tests and staging.",
206
206
  "--json writes one object to stdout (the door's body) and nothing else; a refusal is { ok: false, error, detail } and exits 1.",
@@ -210,53 +210,67 @@ export const TOWER_OPS_COMMAND_HELP = [
210
210
  [
211
211
  "manager",
212
212
  [
213
- "Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
213
+ "Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|drop|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
214
214
  "",
215
215
  "Tower Manager's clients, from the terminal: each client's Home and \"Where we are\", its actions and its timeline items, the same things the manager page does.",
216
216
  " cockpit manager clients every client, with its id",
217
- " cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt",
217
+ " cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt (a summary check says what it did to the card, not \"0 pages seen\"). A run id is a UUID from `queue <client> --json` (a row's changedByRunId), `show <id>` (a suggested change's runId) or `client <client> --json` (lastBriefRebuild.runId); a read's workflow id (lastRead.workflowRunId, like sched_wf_…) is not one",
218
218
  " cockpit manager home <client> the client's Home: how fresh its data is (last read, when Notion last changed), \"Where we are\" (health, headline, up to 5 dated bullets,",
219
- " next step, blocker, who wrote it and when), live counts, what needs attention by owner (overdue and due within 7 days), what is coming up,",
220
- " what the newest read that changed anything did, and how many sync changes wait for review (`queue <client>` lists them).",
221
- " cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <text>] [--bullet \"YYYY-MM-DD: text\"]... [--next <text>|none] [--blocker <text>|none]",
219
+ " next step, blocker, who wrote it and when), live counts, what needs attention by owner in two parts, \"Due this week\" first (today to six days ahead) then \"Overdue\" (rows sit under their owners, oldest first under each; a line \"Overdue longest: <title>, due <day>\" names the one overdue longest), each with its own total and, when cut, \"Showing X of N\" and the `list` command for the rest, what is coming up,",
220
+ " what the newest read that changed anything did (proposals first), \"In Tower, last 7 days\" (what people and agents added, closed, dropped and dismissed, and who, by name; a count of none is left out, and when all are none: \"nothing added, closed, dropped or dismissed by people or agents\"), and a What changed tally that likewise leaves out a kind with none (\"12 added, 2 proposed\"; \"nothing changed\" when none), and the review line (\"N suggested changes need a decision · M new items to check\"; `queue <client>` lists them), and how many sync changes are \"new from syncs\" (`queue <client>` lists them; `list` counts every unreviewed item as \"need review\", the first read's included).",
221
+ " Home also says how old the last check of the card is (\"Last checked 2 h ago\"), \"The last check failed; showing the card from Oct 5\" when it did, \"Last checked\" only once a check has looked at the card (a card a person wrote has none yet), a failed read's reason in the page\'s own sentence (\"Why the latest read failed: ...\"), and \"First read not done yet\" for a client no read has finished (\"First read failed\" when it failed: the manager page shows only that failure until a read succeeds, though writes made here are saved); a card computed from counts reads \"AI unavailable · computed from counts\".",
222
+ " cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <text>] [--bullet 'YYYY-MM-DD: text']... [--next <text>|none] [--blocker <text>|none]",
222
223
  " edit \"Where we are\": any field alone, or several at once. --bullet is given 1 to 5 times and REPLACES the whole list; a leading",
223
224
  " \"YYYY-MM-DD: \" dates a bullet, without it the bullet is undated. none clears --next or --blocker. blocked needs a blocker (given, or already on the card),",
224
- " and a blocker is kept only while the card is Blocked: with any other health it is dropped.",
225
+ " and a blocker goes only on a Blocked card: sent with any other health it is refused (summary_blocker_needs_blocked); sending only a new health drops the stored one.",
225
226
  " --version is summary.updatedAt from `home <client> --json`, or none when the client has no card yet (then --health, --headline and at least one --bullet are all needed).",
226
- " Limits: headline 140 characters, each bullet 160, next and blocker 200. The edit is marked as yours and stands until the client's",
227
- " data changes or an action goes overdue; the next check after that writes the card again (newest wins).",
227
+ " Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is marked as yours and stands until the client's",
228
+ " data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes the card again (newest wins).",
229
+ " A saved card that says On track while an open action is more than 14 days overdue is saved anyway and prints a \"Warning: ...\" line naming the action overdue longest.",
230
+ " Write the card after your item writes: your own later item writes count as data changes, and the next check rewrites the card.",
231
+ " Health rule: an open action more than 14 days overdue, as `cockpit manager home` shows it (Tower's today, America/Vancouver), means the card is at_risk (or blocked, with a named blocker).",
232
+ " Do not set on_track over one; tell the person instead. The headline or a bullet must name the action overdue longest, with the day it was due.",
228
233
  " cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name>|--no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]",
229
234
  " actions (default), or with --timeline the timeline; --status, --owner, --no-owner and --overdue (open actions due before today) are for actions, --kind for the timeline.",
230
235
  " The timeline reads top down: undated items (\"Needs a date\"), then upcoming, a Today line, then the past, newest first (today is Vancouver's day).",
231
- " Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut.",
232
- " cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it)",
236
+ " Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut. With --owner or --no-owner the counts under the heading are that filter's (\"N open actions match these filters (M overdue)\"), not the client's. Times print as Tower's day and time (America/Vancouver, \"Oct 16, 1:03 AM PDT\"); --json keeps the raw ISO.",
237
+ " cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it): \"Needs your decision\" first (a suggested change waits on each), then \"New items to check\"",
233
238
  " an empty queue does not mean everything is reviewed: list <client> --needs-review --status all, and list <client> --timeline --needs-review",
234
- " cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids, what a sync changed",
235
- " cockpit manager add <client> --title <text> [--owners \"A, B\"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
239
+ " cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids and, beneath each, the page and quote the AI cited for it (or \"not found in the page\"), what a sync changed, and who added, edited, reviewed, closed or archived it (by name)",
240
+ " cockpit manager add <client> --title <text> [--owners 'A, B'] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
236
241
  " cockpit manager add <client> --timeline --kind meeting|document|milestone|internal --title <text> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
237
242
  " no --date makes an undated timeline item, which lands in \"Needs a date\"",
238
243
  " --source-url (an http(s) link) and --source-note say where it came from, as the web form's Source does; show prints them",
239
- " cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners \"A, B\"|none]",
244
+ " cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners 'A, B'|none]",
240
245
  " on a timeline item: [--kind <kind>] [--date <date>|none] [--owner <one name>|none]",
241
- " cockpit manager done <action> --version <v> tick an action off (an AI action you tick off counts as reviewed by you, and suggested changes it makes moot close); reopen <action> --version <v> open it again",
242
- " cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it",
246
+ " an edit marks the item reviewed by you (an AI item's [needs review] becomes [reviewed], you the reviewer), even a detail-only one; the AI can only propose changes to it afterwards",
247
+ " cockpit manager done <action> --version <v> tick an action off (it marks the action reviewed by you, an AI action too, and suggested changes it makes moot close; on an action already done it writes nothing); reopen <action> --version <v> open it again",
248
+ " cockpit manager drop <action> --version <v> drop an action: the work no longer applies and was NOT done (a dropped action is not a done one: summaries and the page tell them apart). Add a dated line saying why to its detail first (`edit <id> --version <v> --detail <text>` replaces the detail: keep what `show <id>` printed, add \"YYYY-MM-DD: dropped because ...\"). Prints \"Dropped: <title>.\", or \"No change; nothing was written.\" when it was already dropped; reopen undoes it. Marks the action reviewed by you when the status changes. On an older Tower server it says it can\'t drop yet; mark it done with a dated line saying why instead.",
249
+ " cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it (either marks the action reviewed by you)",
243
250
  " cockpit manager approve <id> --version <v> [--timeline] [--restore] the AI's item is right: mark it reviewed; --restore brings a DISMISSED item back into the lists, marked reviewed by you (without it, a dismissed item is refused)",
244
251
  " cockpit manager dismiss <id> --version <v> [--timeline] the AI's item is wrong: dismiss it (kept, hidden from the lists)",
245
252
  " cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline] decide one suggested change; add-new makes it an item of its own",
246
253
  " cockpit manager archive <id> --version <v> [--timeline] delete an item: it moves to the Deleted list (list --archived), kept; unarchive <id> --version <v> brings it back",
247
254
  "approve, dismiss and proposal decide the AI's suggestion as YOU: your name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it.",
255
+ "Marking reviewed sets the review state to reviewed, with you as the reviewer and the time of the write: link always does it, and edit does whenever it changes something, even a detail-only one (an edit whose fields all equal the stored ones prints \"No change; nothing was written.\", with no new version and no reviewed mark); done, drop and reopen do it only when the status changes (on an action already in that status nothing is written); approve and an accepted proposal do it too. Dismiss sets it to dismissed with you as the reviewer; archive, unarchive and a proposal you dismiss or add-new leave the item's review state as it was.",
256
+ "Free text without shell quoting: add, edit and summary take --fields-file <path> (or - for stdin), one JSON object whose keys are the flags in snake_case, so a $12k, an apostrophe, a double quote or an = reaches Tower exactly as written. The file is UTF-8 (a UTF-16 file with a BOM is read too; any other encoding is refused): in Windows PowerShell write it with `-Encoding utf8` (a `>` redirect makes UTF-16 and Set-Content makes the ANSI code page, which breaks a ’), and for stdin on 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` first.",
257
+ " add: title, detail, owners, owner, due, date, kind, meeting, source_url, source_note, request_id; edit: title, detail, owners, owner, due, date, kind; summary: health, headline, bullets, next, blocker.",
258
+ " owners is a list or \"A, B\"; bullets is a list of \"YYYY-MM-DD: text\" strings or {\"date\": \"YYYY-MM-DD\" or null, \"text\": ...} objects; null (or none) clears owners, owner, due, date, next or blocker.",
259
+ " --version and --timeline stay flags. A field given as a flag and in the file is refused, so is an unknown key. The file gets the flags' own checks. Example: cat fields.json | cockpit manager summary Acme --version none --fields-file -",
260
+ "Free text (the flags above and every value in a fields file) that holds a provider key (an sk-, rk_, sb_secret_, ghp_, github_pat_, AKIA/ASIA, xoxb- key, any JWT, a PEM private key) or a password given a value (\"password: x\") is refused, naming the flag or key; ordinary words, a commit sha, a URL and the words \"service-role key\" are plain text.",
261
+ "On a flag, free text (--headline, --bullet, --title, --detail, --next, --blocker, --owners, --source-note) goes in SINGLE quotes so the shell leaves $ alone: \"$12k\" in double quotes loses the $12k (zsh and bash read it as a variable). In bash and zsh write an apostrophe inside single quotes as '\\'' (end the quote, a backslash-escaped apostrophe, reopen); in PowerShell double the apostrophe ('Rowan''s deck'). The command prints what it saved: check it is your words.",
248
262
  "<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are reviewed already.",
249
263
  "--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
250
264
  "since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
251
265
  "--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",
252
266
  "--owners none clears them. A timeline item has ONE owner: --owner <name> or --owner none. The same pattern clears a date: --due none, --date none.",
253
267
  "Owner names are kept as written, not matched to accounts: use the spelling `list` shows, so one person stays one name. A flag you leave out is never sent, and a flag given twice is refused (all but summary's --bullet). A date is YYYY-MM-DD. `cockpit manager`, `manager help` and `manager --help` print this and change nothing.",
254
- "add always sends a request id (--request-id <uuid>, else a fresh one) and prints it. If Tower does not answer, nothing was confirmed: rerun the same command with",
255
- "--request-id <id> and it retries without making a duplicate.",
268
+ "add always sends a request id and prints it: leave --request-id out on a first add (a fresh one is made). If Tower does not answer, nothing was confirmed: rerun the same command with the",
269
+ "--request-id the CLI printed and it retries without making a duplicate (\"Already added earlier under this request id; nothing new was written.\"). Never make an id up: reusing one for a different item is refused (request_reused): the request id IS the item's id, so `show <request-id>` shows it, a retry already made it, and a different item is added without --request-id. An add prints the detail (cut to 200 characters) and the source it saved, so you can check they are your words.",
256
270
  "--json writes the door's body to stdout, verbatim, and nothing else (an add's body also carries requestId); a refusal is { ok: false, error, detail, refusal? } and exits 1.",
257
271
  "Progress lines on stderr start with [tag]; stdout is the answer (2>/dev/null hides them). Creating a client, syncing, Rebuild brief and merging owners are on the manager page only. Teaching: docs/architecture/tower-manager.md.",
258
272
  "The bli-tower MCP server has the twins: manager_clients, manager_client, manager_home, manager_set_summary (summary), manager_run, manager_list, manager_queue, manager_show, manager_add, manager_edit,",
259
- "manager_done, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
273
+ "manager_done, manager_drop, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
260
274
  ],
261
275
  ],
262
276
  [
@@ -47,7 +47,7 @@ export const TOWER_COMMAND_HELP = [
47
47
  [
48
48
  "docs",
49
49
  [
50
- "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>] [flags]",
50
+ "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>|share <id|slug> --add|--remove <email>|access <id|slug>] [flags]",
51
51
  "",
52
52
  "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
53
53
  "list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
@@ -67,20 +67,21 @@ export const TOWER_COMMAND_HELP = [
67
67
  " --request-id <uuid> makes a retry apply once (minted and printed when absent). Reusing it with different input is request_id_reused. If the response is lost, run `cockpit docs mutation-status <uuid>`, then retry the SAME input with the SAME id.",
68
68
  "mutation-status <request-uuid> — your own save receipt: committed (with the stored revision), not_committed (with the reason) or unknown_check_again (no receipt yet; it never proves nothing changed). --json shape: { mutation }.",
69
69
  " --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
70
+ "access <id|slug> — who may do what on a page and why, in plain sentences (BLI-21313): your role (author, admin, granted_editor, reader), can_read/can_edit/can_manage, the named editors you may see (the owner and administrators see all; a named editor sees only their own grant), and the grant history for the owner and administrators. The same explanation the page, its versions and its export carry. --json shape: { ok, document_id, permission: { role, can_read, can_edit, can_manage, editors, editors_complete, reasons }, events }.",
71
+ "share <id|slug> --add <email> | --remove <email> [--reason \"<note>\"] — owner or administrator only. Names (or removes) an editor of an ORGANIZATION page by exact email, never fuzzy. An editor can change the title and text; they cannot change visibility, move, delete or manage editors, and nobody new gains the right to read. A page you made private refuses with private_page_grant_needs_decision (sharing a private page waits for a decision) and nothing is written. Other refusals: not_grant_manager, person_not_found, grantee_is_author, document_not_found_or_unreadable. Adding someone already named, or removing someone not named, answers outcome unchanged. --json shape: { ok, outcome, reason, document_id, grantee_id, request_id, permission }.",
70
72
  "delete <id|slug> [<id|slug>...] — soft-deletes one or more documents (BLI-10555): the browser's own delete, sets deleted_at, never a real SQL DELETE. Recoverable — only a super_admin clearing deleted_at brings it back, there is no self-service undo. Deleting a parent does NOT delete its children; they keep their own parent_id and stay exactly where they were. Several refs resolve against ONE shared list call, then one DELETE each — one bad ref never loses the rest. --json shape: one ref answers { ok, document_id }; several answer { documents: [{ ref, ok, document_id } | { ref, ok: false, error, detail }] }.",
71
73
  "link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
72
74
  "sync-status [<id|slug>] — every linked document's Notion sync state, or one's: synced (the body is what both sides last agreed on), tower_changed (edited here since; the next write-back pushes it), never_synced (linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull, not here. Metadata only, never a body.",
73
75
  "export <id|slug> [--format md|pdf|docx|txt] [--out <path>] — writes the document as a file (ticket 20694): md is the stored Markdown exactly, txt is readable plain text, docx is a Word file with real headings, lists, tables and links, pdf is a paginated page with the title and last-updated date in the header. Default format md; default path is the document's slug plus the extension in the current directory (--out names a file, or a directory to put it in), and an existing file is replaced. Prints the path; --json shape: { ok, path, bytes, format }. To copy a page's text, `cockpit docs read` already does it.",
74
76
  "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
75
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
76
- "A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
77
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
77
78
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
78
79
  ],
79
80
  ],
80
81
  [
81
82
  "msg",
82
83
  [
83
- "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
84
+ "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|edit <message-id>|delete <message-id>|outcome <request-uuid>|thread <id> --channel <channel>|show <message-id>] [flags]",
84
85
  "",
85
86
  "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
86
87
  "channels — every channel you are a member of (or, as a super_admin, every channel).",
@@ -88,7 +89,8 @@ export const TOWER_COMMAND_HELP = [
88
89
  "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
89
90
  "read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
90
91
  "unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
91
- "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
92
+ "send <channel> [--thread <id>] [--request-id <uuid>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`. Every send carries a request id (minted when you give none); retry a send whose answer was lost with the SAME --request-id and the same text and it posts once. The same id with different text is refused request_id_reused, not applied as an edit.",
93
+ "edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying.",
92
94
  "thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
93
95
  "show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false.",
94
96
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -102,7 +104,7 @@ export const TOWER_COMMAND_HELP = [
102
104
  "Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>|versions <id>|restore <id> --version <n>|mutation-status <request-uuid>] [flags]",
103
105
  "",
104
106
  "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
105
- "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
107
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--label <name>] [--q <text>] [--created-after|--created-before|--updated-after|--updated-before <utc instant|date>] [--sort updated_at|created_at] [--dir desc|asc] [--limit <n>] [--cursor <c>] [--all] — the issues you may see, most recently updated first. A page is 100 by default and at most 500. Filters run in the database BEFORE the page, so a label or title match deep in the set is found. --q is a case-insensitive title contains (% and _ are literal); *-after is inclusive, *-before is exclusive. --json adds next_cursor, has_more, coverage (complete|capped|failed_partial), requested, returned, unique: pass next_cursor back as --cursor with the same other flags. --all walks every page and streams ONE JSON OBJECT PER LINE (NDJSON; rows carry assignee_id, no names), then a final {\"type\":\"coverage\",...} line naming the coverage; an interrupted walk ends with coverage failed_partial, the reason and the resume cursor, and exits 1. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
106
108
  "board [--json] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <uuid>] [--label <name>] [--rows <state:n,...>] — the Tickets board, counts, coverage and top five cards per column, through GET /api/work/board. Also cockpit tickets board.",
107
109
  "show <id> [<id>...] — one or more issues: title, state, priority, size, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
108
110
  " --json shape: one id answers { issue, comments }, unchanged from before BLI-10450 (plus `estimate`/`size`, BLI-10575). Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
@@ -112,7 +114,7 @@ export const TOWER_COMMAND_HELP = [
112
114
  "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
113
115
  "Writes accept --request-id <uuid> and --expected-revision <n>. Keep the UUID and identical fields across retries; JSON includes mutation receipt and mirror delivery separately.",
114
116
  "mutation-status <request-uuid> — check your own local commit and mirror delivery after a lost response; no receipt means unknown_check_again.",
115
- "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
117
+ "history <id> [--limit <n>] [--cursor <c>] — every recorded state move and reassignment, oldest first; --json carries the same page fields as list.",
116
118
  "versions <id> [--limit <n>] — every saved description, newest first, each with its number, id, author, source and time (BLI-21300). --json shape: { versions: [{ id, version, markdown, author_id, source, restored_from_version, created_at }] }.",
117
119
  "restore <id> --version <n> [--request-id <uuid>] [--expected-revision <n>] — writes version <n>'s text back as a NEW version; nothing is rewritten, so the restore can itself be undone. A stale revision conflicts and changes nothing. --json shape: { issue, mutation }.",
118
120
  "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
@@ -151,7 +153,7 @@ export const TOWER_COMMAND_HELP = [
151
153
  [
152
154
  "cal",
153
155
  [
154
- 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
156
+ 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
155
157
  "",
156
158
  "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
157
159
  "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
@@ -161,7 +163,7 @@ export const TOWER_COMMAND_HELP = [
161
163
  "conflicts [--from <iso> --to <iso>] [--tz <IANA>] — own-account double-bookings with duplicate invitation merging, checked calendars and age. Visible calendars only by default; --all includes hidden calendars. All-day and free events never conflict.",
162
164
  "event <id> [--occurrence-start <iso>] [--tz <zone>] [--json] — exact occurrence details, human recurrence, named guests/RSVP, organizer, source zone, HTTPS Join and edit capability. The selector must match a real occurrence; cancelled/missing instances are refused.",
163
165
  "export <id> [--occurrence-start <iso>] [--json] — iCalendar text to stdout; redirect to event.ics. Recurring exports include the complete series and exceptions, keeping source zone/recurrence. No calendar credentials.",
164
- "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained.",
166
+ "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained. view-settings [--json] reads your saved view settings (default view, N days, week start, weekends, density, up to 3 extra time zones) plus the calendar actions and keyboard shortcuts; never saved means the defaults, said so. view-settings-change [--view day|week|days|month] [--days 2-9] [--week-start monday|sunday] [--hide-weekends on|off] [--density normal|compact] [--zone <IANA>]... changes only what you name; --zone repeats (up to 3), --zone none clears; stdin JSON like {\"day_count\":4} works too.",
165
167
  "calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
166
168
  "add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
167
169
  ' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
@@ -170,6 +172,8 @@ export const TOWER_COMMAND_HELP = [
170
172
  " A work @buildlaunchiterate.ca calendar does not need this: connect Google in the browser and `cockpit cal calendars` will list them.",
171
173
  'create [--account <name>|--calendar <id>] --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] [--meet] — creates the event AT GOOGLE, then here. Attendees are invited by Google; --meet asks for a Google Meet room and prints its address (or says meet_not_available and keeps the event).',
172
174
  " Only a Google calendar can be written to: an iCal secret address is a read address, and `write_not_supported_for_provider` says so by name.",
175
+ "rsvp <event id> --response accepted|declined|tentative [--comment \"<text>\"] [--scope instance|series] [--occurrence <iso>] [--account <name>] [--key <k>] — answers YOUR invitation; only your own guest entry changes and nobody else is emailed by Tower. Refuses with not_a_guest when you are not on the guest list.",
176
+ "change <event id> [--title ..] [--at <iso>] [--until <iso>] [--location ..] [--scope instance|following|series] [--occurrence <iso>] [--color 1-11|none] [--reminder <minutes[:popup|email]>|default]... [--visibility default|public|private|confidential] [--transparency opaque|transparent] — a recurring event REQUIRES --scope (instance and following also need --occurrence): instance = this event, following = this and following events (starts a NEW series), series = all events. --reminder repeats, at most 5; `default` alone means the calendar default. Refusals keep the door's reason (scope_required, occurrence_required, scope_not_supported, following_is_series, write_refused). cancel <event id> --yes [--scope instance|following|series] [--occurrence <iso>] [--account <name>] — same scope rules as change; `following` trims the series and prints the new end. create also takes --color, --reminder, --visibility and --transparency.",
173
177
  "share <id> --org-visible|--private — makes one of YOUR calendars readable by everyone at BLI, or private again. Sharing never lets anybody else change it.",
174
178
  "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
175
179
  "sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
@@ -188,10 +192,10 @@ export const TOWER_COMMAND_HELP = [
188
192
  [
189
193
  "project",
190
194
  [
191
- "Usage: cockpit project [list] [--archived] [--json]",
195
+ "Usage: cockpit project [list] [--archived] [--limit <n>] [--cursor <c>] [--json]",
192
196
  "",
193
197
  "The projects issues are filed under. Bare `cockpit project` lists them.",
194
- "list [--archived] — id, active/archived, name. --archived includes archived projects.",
198
+ "list [--archived] [--limit <n>] [--cursor <c>] — id, active/archived, name. --archived includes archived projects. A page is at most 500; when more exist the output (and --json: next_cursor, has_more, coverage, requested, returned, unique) carries the cursor to pass back as --cursor.",
195
199
  "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
196
200
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
197
201
  ],
@@ -144,7 +144,7 @@ const COMMAND_SUMMARY_LINES = [
144
144
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
145
145
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
146
146
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
147
- " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
147
+ " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
148
148
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
149
149
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
150
150
  ` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `cockpit manager add|edit|summary --fields-file <path>|-` (BLI-5777, findings Q-10 and Q-33): the free text of a write
3
+ * as one JSON object, read from a file or from stdin, so no shell quoting touches it (`$12k`, an apostrophe, a double
4
+ * quote on Windows PowerShell 5.1, an `=`).
5
+ *
6
+ * The keys are the flag names in snake_case. This module only READS: it checks the JSON's shape, refuses an unknown key
7
+ * or a key also given as a flag, runs the secret guard on every string, and turns each key into the same value the flag
8
+ * would have carried. The parser (`local-args-tower-manager.ts`) then runs the one set of field readers over the merged
9
+ * values, so a file value gets exactly the flag's validation (`none` clears, blank is refused, dates are real days).
10
+ *
11
+ * `--version` and `--timeline` stay flags: the version guards a write against a stale read, and `--timeline` picks the table.
12
+ */
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import { isInteractiveStdin } from "./cli-io.js";
15
+ import { rejectSecretLikeValue } from "./local-arg-values.js";
16
+ /** The keys a fields file may hold, per verb: every value flag the verb owns except `--version`. */
17
+ export const FIELDS_FILE_KEYS = {
18
+ add: ["title", "detail", "owners", "owner", "due", "date", "kind", "meeting", "source_url", "source_note", "request_id"],
19
+ edit: ["title", "detail", "owners", "owner", "due", "date", "kind"],
20
+ summary: ["health", "headline", "bullets", "next", "blocker"],
21
+ };
22
+ /** Keys whose JSON `null` means "clear it", the same as the flag's `none`. */
23
+ const NULLABLE = new Set(["owners", "owner", "due", "date", "next", "blocker"]);
24
+ /** A file is a few fields of text; this stops a wrong file (a log, a dump) being read whole. */
25
+ export const FIELDS_FILE_MAX_BYTES = 256 * 1024;
26
+ const flagOf = (key) => `--${key.replace(/_/g, "-")}`;
27
+ /** A key is the person's own spelling: cut and cleaned before it goes into a sentence. */
28
+ const shown = (key) => key.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").slice(0, 40);
29
+ /** The sentence for bytes that are not UTF-8 text (a Windows PowerShell 5.1 `>` file is UTF-16, a `Set-Content` file is the ANSI code page). */
30
+ export const NOT_UTF8 = "The file is not UTF-8. Save the file as UTF-8 (PowerShell: -Encoding utf8) and run it again.";
31
+ /**
32
+ * The bytes of a fields file as text: a UTF-8 BOM is dropped, a UTF-16 LE or BE BOM is decoded (PowerShell 5.1's `>` and Out-File write UTF-16 LE with a
33
+ * BOM), and anything else must be well-formed UTF-8: a file in the ANSI code page (a `’` saved by Set-Content) is refused, never read as "�".
34
+ */
35
+ export function decodeFieldsBytes(bytes) {
36
+ // UTF-16 without a BOM (a program that wrote it raw) is full of NUL bytes, which no UTF-8 text holds: say what is wrong, not "not valid JSON (near character 1)".
37
+ const utf16Bom = bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff));
38
+ if (!utf16Bom && bytes.includes(0))
39
+ throw new Error(NOT_UTF8);
40
+ try {
41
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
42
+ return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
43
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
44
+ return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
45
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes.subarray(bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0));
46
+ }
47
+ catch {
48
+ throw new Error(NOT_UTF8);
49
+ }
50
+ }
51
+ /** Everything the stream holds, as bytes (a string chunk, as a test gives, is its UTF-8), refused over `max` bytes. */
52
+ async function readStreamBytes(stream, max, overflow) {
53
+ const chunks = [];
54
+ let total = 0;
55
+ for await (const chunk of stream) {
56
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
57
+ total += bytes.length;
58
+ if (total > max)
59
+ throw new Error(overflow);
60
+ chunks.push(bytes);
61
+ }
62
+ return Buffer.concat(chunks);
63
+ }
64
+ /** The text of the fields file: `-` is stdin (piped), anything else a path. Every failure is a sentence that says what to do. */
65
+ export async function readFieldsText(source, io) {
66
+ if (source === "-") {
67
+ if (isInteractiveStdin(io)) {
68
+ throw new Error("--fields-file - reads the JSON from stdin, but stdin is a terminal. Pipe it in (`cat fields.json | cockpit manager ... --fields-file -`) or give a file path.");
69
+ }
70
+ const bytes = await readStreamBytes(io.stdin, FIELDS_FILE_MAX_BYTES, `The fields JSON on stdin is longer than ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check you piped the right file.`);
71
+ let text;
72
+ try {
73
+ text = decodeFieldsBytes(bytes);
74
+ }
75
+ catch {
76
+ // From a pipe, Windows PowerShell 5.1 encodes with $OutputEncoding (ASCII by default): the way out is its setting, not the file.
77
+ throw new Error("The JSON on stdin is not UTF-8. In Windows PowerShell 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` before piping, or save the JSON as UTF-8 (PowerShell: -Encoding utf8) and pass its path.");
78
+ }
79
+ if (text.trim() === "")
80
+ throw new Error("--fields-file - got nothing on stdin. Pipe in a JSON object, like {\"title\": \"Send the SOW\"}.");
81
+ return text;
82
+ }
83
+ let info;
84
+ try {
85
+ info = await stat(source);
86
+ }
87
+ catch (error) {
88
+ const code = error.code;
89
+ throw new Error(code === "ENOENT" ? `--fields-file ${source}: no such file.` : `--fields-file ${source}: cannot be read (${code ?? "unknown error"}).`);
90
+ }
91
+ if (!info.isFile())
92
+ throw new Error(`--fields-file ${source} is not a file.`);
93
+ if (info.size > FIELDS_FILE_MAX_BYTES) {
94
+ throw new Error(`--fields-file ${source} is ${Math.ceil(info.size / 1024)} KB; the limit is ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check it is the right file.`);
95
+ }
96
+ let bytes;
97
+ try {
98
+ bytes = await readFile(source);
99
+ }
100
+ catch (error) {
101
+ throw new Error(`--fields-file ${source}: cannot be read (${error.code ?? "unknown error"}).`);
102
+ }
103
+ const text = decodeFieldsBytes(bytes);
104
+ if (text.trim() === "")
105
+ throw new Error(`--fields-file ${source} is empty. Put a JSON object in it, like {"title": "Send the SOW"}.`);
106
+ return text;
107
+ }
108
+ function parseObject(raw) {
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch (error) {
114
+ // The engine's message quotes the file's own words: only the position is kept.
115
+ const at = /position (\d+)/.exec(error instanceof Error ? error.message : "")?.[1];
116
+ throw new Error(`The fields file is not valid JSON${at ? ` (near character ${at})` : ""}. It is one object: {"title": "Send the SOW", "detail": "Mo said \\"ok\\""}. A quote inside a value is written \\", a new line \\n.`);
117
+ }
118
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
119
+ throw new Error("The fields file must be one JSON object, like {\"title\": \"Send the SOW\"}, not a list or a single value.");
120
+ }
121
+ return parsed;
122
+ }
123
+ /** A string value: of the right type, and not a secret. */
124
+ function text(key, value) {
125
+ if (typeof value !== "string")
126
+ throw new Error(`The fields file's "${key}" must be text${NULLABLE.has(key) ? " (or null to clear it)" : ""}.`);
127
+ rejectSecretLikeValue(value, `the fields file's "${key}"`);
128
+ return value;
129
+ }
130
+ /**
131
+ * Folds the file's fields into `values` as the flags they stand for, and returns the file's bullets (they have a
132
+ * shape of their own). Throws a sentence for: bad JSON, an unknown key, a key also given as a flag, a wrong type, a secret.
133
+ */
134
+ export function mergeFieldsFile(values, verb, fileText) {
135
+ const fields = parseObject(fileText);
136
+ const allowed = FIELDS_FILE_KEYS[verb];
137
+ const keys = Object.keys(fields);
138
+ for (const key of keys) {
139
+ if (key === "version" || key === "timeline") {
140
+ throw new Error(`"${key}" is not a field: give ${flagOf(key)} as a flag. It is not read from the fields file.`);
141
+ }
142
+ if (!allowed.includes(key)) {
143
+ throw new Error(`The fields file has a key "${shown(key)}" that \`cockpit manager ${verb}\` does not take. It takes: ${allowed.join(", ")}.`);
144
+ }
145
+ const flag = key === "bullets" ? "--bullet" : flagOf(key);
146
+ if (values.flags.has(flag) || values.flagValues.has(flag)) {
147
+ throw new Error(`${key} is given both as ${flag} and in the fields file. Give it once, in one place.`);
148
+ }
149
+ }
150
+ const out = { keys: keys.length };
151
+ for (const key of keys) {
152
+ const value = fields[key];
153
+ if (key === "bullets") {
154
+ out.bullets = readBulletList(value);
155
+ continue;
156
+ }
157
+ const flag = flagOf(key);
158
+ if (value === null) {
159
+ if (!NULLABLE.has(key))
160
+ throw new Error(`The fields file's "${key}" must be text; null clears only ${[...NULLABLE].join(", ")}.`);
161
+ values.flags.set(flag, "none");
162
+ }
163
+ else if (key === "owners" && Array.isArray(value)) {
164
+ values.flags.set(flag, ownersText(value));
165
+ }
166
+ else {
167
+ // An empty string is allowed from a file (it can clear a detail); the shell parser refuses an empty flag value.
168
+ values.flags.set(flag, text(key, value));
169
+ }
170
+ values.flagValues.set(flag, [values.flags.get(flag)]);
171
+ }
172
+ return out;
173
+ }
174
+ /** `["Pip Moss", "Rowan Ash"]` as the flag's `Pip Moss, Rowan Ash`; `[]` clears (`none`), like the flag's `none`. */
175
+ function ownersText(list) {
176
+ if (list.length === 0)
177
+ return "none";
178
+ const names = list.map((entry) => {
179
+ const name = text("owners", entry).trim();
180
+ if (name === "")
181
+ throw new Error("The fields file's \"owners\" has an empty name. Give names, or [] or null to clear them.");
182
+ if (name.includes(","))
183
+ throw new Error("A name in the fields file's \"owners\" cannot hold a comma: Tower splits people on commas. Write the name without it.");
184
+ return name;
185
+ });
186
+ return names.join(", ");
187
+ }
188
+ function readBulletList(value) {
189
+ if (!Array.isArray(value))
190
+ throw new Error("The fields file's \"bullets\" must be a list: [\"2026-10-14: Signed the SOW\", {\"date\": null, \"text\": \"Export still missing\"}].");
191
+ return value.map((entry, index) => {
192
+ if (typeof entry === "string")
193
+ return text("bullets", entry);
194
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
195
+ const { date, text: words, ...rest } = entry;
196
+ const extra = Object.keys(rest)[0];
197
+ if (extra !== undefined)
198
+ throw new Error(`Bullet ${index + 1} in the fields file has a key "${shown(extra)}"; a bullet is {"date": "YYYY-MM-DD" or null, "text": "..."}.`);
199
+ if (date !== null && date !== undefined && typeof date !== "string")
200
+ throw new Error(`Bullet ${index + 1}'s "date" must be "YYYY-MM-DD" or null.`);
201
+ if (typeof words !== "string")
202
+ throw new Error(`Bullet ${index + 1}'s "text" must be text.`);
203
+ rejectSecretLikeValue(words, `the fields file's "bullets"`);
204
+ return { date: date ?? null, text: words };
205
+ }
206
+ throw new Error(`Bullet ${index + 1} in the fields file must be a "YYYY-MM-DD: text" string or a {"date", "text"} object.`);
207
+ });
208
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What `cockpit manager` tells a person after a refusal, and the limits it tells them (BLI-5777).
3
+ *
4
+ * A leaf module (it imports nothing) so the repo-shape tests can read it without pulling in the collector. Everything here
5
+ * is a COPY of something the manager server owns, and the CLI cannot import the server:
6
+ *
7
+ * - `MANAGER_LIMITS_COPY` and `LONG_OVERDUE_DAYS_COPY`: the server's `TOWER_MANAGER_SUMMARY_LIMITS` and `SUMMARY_LONG_OVERDUE_DAYS`;
8
+ * - `SUMMARY_REFUSAL_NAMES`: the server's `TOWER_MANAGER_SUMMARY_REFUSALS`, same names in the same order;
9
+ * - `ITEM_REFUSAL_NAMES`: the server's `ReviewRefusal` (`db/review.ts`) and `item_busy`, less `not_found` (the `not_found` reason answers it);
10
+ * - `READ_ONLY_REFUSAL_NAMES`: the door's `ReadOnlyRefusalReason`.
11
+ *
12
+ * `apps/dashboard/src/repo-shape/manager-copies.test.ts` fails the moment a copy differs from the server's, and each hint map
13
+ * is typed against its own name list (`satisfies Record<…>`), so a name added to the list without a hint fails `tsc`.
14
+ * A new module like this one joins `scripts/build-public-cli.mjs` `runtimeFiles` or the packed CLI breaks while the tests stay green.
15
+ */
16
+ export const MANAGER_LIMITS_COPY = { headlineMax: 140, bulletsMinPerson: 1, bulletsMax: 5, bulletMax: 160, nextMax: 200, blockerMax: 200 };
17
+ /** More than this many days past due, an open action makes the card at risk: a copy of the server's `SUMMARY_LONG_OVERDUE_DAYS`. */
18
+ export const LONG_OVERDUE_DAYS_COPY = 14;
19
+ const L = MANAGER_LIMITS_COPY;
20
+ export const SUMMARY_REFUSAL_NAMES = [
21
+ "summary_changed", "summary_empty", "summary_incomplete", "summary_health_invalid", "summary_headline_empty", "summary_headline_too_long",
22
+ "summary_bullets_count", "summary_bullet_empty", "summary_bullet_too_long", "summary_bullet_date_invalid", "summary_next_too_long",
23
+ "summary_blocker_too_long", "summary_blocked_needs_blocker", "summary_blocker_needs_blocked",
24
+ ];
25
+ /** The way forward after each named refusal of a "Where we are" edit; `home` is the ready-to-run `cockpit manager home <client> --json`. */
26
+ export function summaryHints(home) {
27
+ // A hint is the next step in this terminal's own words (the flag to pass, the command to run). It never says the server's sentence again.
28
+ return {
29
+ summary_changed: `Read the card again with ${home} and retry with its summary.updatedAt as --version (none only when it has no card).`,
30
+ summary_empty: "Pass at least one of --health, --headline, --bullet, --next, --blocker.",
31
+ summary_incomplete: "Pass --health, --headline and --bullet together, with --version none.",
32
+ summary_health_invalid: "Pass --health with one of those words, in lower case with underscores.",
33
+ summary_headline_empty: "A blank or invisible-only --headline counts as empty.",
34
+ summary_headline_too_long: `Shorten --headline: the limit (${L.headlineMax}) counts characters, not bytes.`,
35
+ summary_bullets_count: "Each --bullet is one bullet, and together they replace the whole list.",
36
+ summary_bullet_empty: "Put words after a --bullet's date, when it has one.",
37
+ summary_bullet_too_long: `Shorten the long --bullet (the limit is ${L.bulletMax}).`,
38
+ summary_bullet_date_invalid: "Start a dated --bullet with \"YYYY-MM-DD: \" and a real day.",
39
+ summary_next_too_long: `Shorten --next (the limit is ${L.nextMax}).`,
40
+ summary_blocker_too_long: `Shorten --blocker (the limit is ${L.blockerMax}).`,
41
+ summary_blocked_needs_blocker: "Add --blocker <what blocks it>, or pass another --health.",
42
+ summary_blocker_needs_blocked: "Add --health blocked, or leave --blocker out (`--blocker none` clears the stored one).",
43
+ };
44
+ }
45
+ export const ITEM_REFUSAL_NAMES = [
46
+ "item_changed", "item_busy", "item_archived", "item_not_archived", "item_dismissed", "person_item_not_dismissable",
47
+ "no_proposal", "proposal_replaced", "timeline_not_live", "request_reused", "proposal_decided", "proposal_not_addable",
48
+ ];
49
+ /** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
50
+ export const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
51
+ /** The way forward after each named refusal of an item write. `flag` is " --timeline" for a timeline item, else "". */
52
+ export function itemHints(id, flag) {
53
+ const reread = `Run \`cockpit manager show ${id}${flag}\` for the current version.`;
54
+ return {
55
+ item_changed: reread,
56
+ item_busy: reread,
57
+ no_proposal: reread,
58
+ proposal_decided: reread,
59
+ // The request id IS the item's id (an add made it): show it; a retry already made it, and a different item needs no request id.
60
+ request_reused: "The request id is that item's id: `cockpit manager show <request-id>` shows it. If you were retrying an add, it is already there: don't add it again. To add a different item, run the add without --request-id.",
61
+ proposal_replaced: `Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`,
62
+ proposal_not_addable: `Run \`cockpit manager proposal ${id} <proposal-id> accept --version <v>\` (or dismiss).`,
63
+ item_archived: `Bring it back with \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`,
64
+ item_dismissed: `Run \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`,
65
+ person_item_not_dismissable: `Run \`cockpit manager archive ${id}${flag} --version <v>\`.`,
66
+ timeline_not_live: "`cockpit manager list <client> --timeline` lists the client's live timeline items.",
67
+ item_not_archived: "Nothing to bring back: it is in the lists already.",
68
+ };
69
+ }
70
+ /**
71
+ * The door's own refusals of a read-only device token (`ReadOnlyRefusalReason`): they reach the terminal as the `reason`
72
+ * (BLI-5777; before, the 403 carried no name and the CLI printed `http_403`).
73
+ */
74
+ export const READ_ONLY_REFUSAL_NAMES = ["read_only_device_cannot_write", "read_only_device_door_not_listed", "read_only_device_on_admin"];
75
+ export const READ_ONLY_HINTS = {
76
+ read_only_device_cannot_write: " Sign in as the person who is asking with `cockpit login`.",
77
+ read_only_device_door_not_listed: " A brain session's key may not read Tower Manager: sign in as the person who is asking with `cockpit login`.",
78
+ read_only_device_on_admin: " Mint the read-only key for a member instead.",
79
+ };
80
+ /** A read's workflow id (`sched_wf_…`, `wf-…`): what `lastRead.workflowRunId` holds in `client --json` and `home --json`. It is not a run's receipt id. */
81
+ export const looksLikeWorkflowId = (value) => /^(?:[a-z]+_)?wf[-_]/i.test(value);
82
+ /** Where a run's receipt id really comes from, for the refusal of anything else (`run <run-id>`). */
83
+ export function runIdRefusal(value) {
84
+ const where = "A run id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10. It comes from `cockpit manager queue <client> --json` (a row's changedByRunId), `cockpit manager show <id>` (a suggested change's runId) or `cockpit manager client <client> --json` (lastBriefRebuild.runId); `list` does not print run ids.";
85
+ return looksLikeWorkflowId(value)
86
+ ? `That is a read's workflow id (the lastRead.workflowRunId that \`client --json\` and \`home --json\` print), not a run's receipt id. ${where}`
87
+ : where;
88
+ }