@bli-cockpit/cli 0.2.240 → 0.2.242

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.
@@ -0,0 +1,61 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { readPipedText, isInteractiveStdin, writeLine } from "./cli-io.js";
3
+ const FAIL_TAG = "[cal cli]";
4
+ const REQUEST_TIMEOUT_MS = 30000;
5
+ const PATH = "/api/cal/view-settings";
6
+ /** `cal view-settings` (read) and `cal view-settings-change` (partial patch). Same door the browser uses. */
7
+ export async function runCalViewSettings(command, door) {
8
+ if (command.action === "view-settings-change")
9
+ return change(command, door);
10
+ const answer = await askAgentDoor(door, { method: "GET", path: PATH, label: "cal view-settings", timeoutMs: REQUEST_TIMEOUT_MS });
11
+ if (!answer.ok)
12
+ return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
13
+ if (door.json)
14
+ return emitAgentDoor(door, answer.body);
15
+ writeSettings(door, answer.body, true);
16
+ return 0;
17
+ }
18
+ async function change(command, door) {
19
+ let body = command.viewSettings ?? {};
20
+ if (!Object.keys(body).length && !isInteractiveStdin(door.io)) {
21
+ const piped = await readPipedSettings(door);
22
+ if (piped === null)
23
+ return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object of settings, or use --view, --days, --week-start, --hide-weekends, --density, --zone.");
24
+ body = piped;
25
+ }
26
+ if (!Object.keys(body).length) {
27
+ return failAgentDoor(door, FAIL_TAG, "invalid_body", "Name a setting: --view month, --days 4, --week-start sunday, --hide-weekends on, --density compact, --zone America/Toronto (or --zone none).");
28
+ }
29
+ const answer = await askAgentDoor(door, { method: "PATCH", path: PATH, body, label: "cal view-settings-change", timeoutMs: REQUEST_TIMEOUT_MS });
30
+ if (!answer.ok)
31
+ return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
32
+ if (door.json)
33
+ return emitAgentDoor(door, answer.body);
34
+ writeLine(door.io.stdout, "View settings saved.");
35
+ writeSettings(door, answer.body, false);
36
+ return 0;
37
+ }
38
+ function writeSettings(door, settings, withActions) {
39
+ const out = door.io.stdout;
40
+ writeLine(out, settings.saved ? `Saved ${settings.updated_at ?? ""}`.trim() : "Not saved yet: these are the defaults.");
41
+ writeLine(out, `default view: ${settings.default_view}${settings.default_view === "days" ? ` (${settings.day_count} days)` : ` · N days: ${settings.day_count}`}`);
42
+ writeLine(out, `week starts: ${settings.week_start} · weekends: ${settings.hide_weekends ? "hidden in week and N-day views" : "shown"} · density: ${settings.density}`);
43
+ writeLine(out, `extra time zones: ${settings.extra_zones.length ? settings.extra_zones.join(", ") : "none"}`);
44
+ if (withActions) {
45
+ writeLine(out, "Keyboard shortcuts:");
46
+ for (const action of settings.actions ?? [])
47
+ writeLine(out, ` ${action.keys.join(" ")} ${action.label}: ${action.description}`);
48
+ }
49
+ }
50
+ /** Piped JSON settings, or null when the pipe is unreadable or not a JSON object. */
51
+ async function readPipedSettings(door) {
52
+ try {
53
+ const parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: 4000, overflowMessage: "Settings are too large." }));
54
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
55
+ return null;
56
+ return parsed;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -24,6 +24,7 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./ag
24
24
  import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
25
25
  import { runCalDetail } from "./cal-detail.js";
26
26
  import { runCalOverlay } from "./cal-overlay.js";
27
+ import { runCalViewSettings } from "./cal-view-settings.js";
27
28
  import { runCalDelete } from "./cal-delete.js";
28
29
  import { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
29
30
  import { runCalDirect } from "./cal-direct.js";
@@ -46,6 +47,9 @@ export async function runCal(command, io) {
46
47
  case "conflicts":
47
48
  case "calendar-change":
48
49
  return runCalOverlay(command, door);
50
+ case "view-settings":
51
+ case "view-settings-change":
52
+ return runCalViewSettings(command, door);
49
53
  case "today":
50
54
  case "week":
51
55
  case "next":
@@ -9,9 +9,13 @@
9
9
  * verbatim: no logic changed, and `normalizeUrl` is still re-exported from
10
10
  * `./local-args.js` for the callers that had it there.
11
11
  *
12
- * `rejectServiceRoleLikeArgument` runs on every token, flag or positional, and
13
- * is why a service-role credential cannot enter a collector command from the
14
- * command line at all.
12
+ * `rejectSupabaseServiceCredential` runs on every token, flag or positional of every command, and is
13
+ * why a Supabase service credential (a service-role JWT, or the newer `sb_secret_…` key) cannot enter a
14
+ * collector command from the command line. It looks at
15
+ * the SHAPE of a value, never at words: a note that says "the Supabase service-role key" is
16
+ * plain text. The wider telemetry-core secret rulebook (`rejectSecretLikeValue`) is NOT run
17
+ * here: it reads words like "password: x" or "M0-…-review" as secrets, which no other command
18
+ * may be refused for. Only `cockpit manager`'s free-text values go through it (BLI-5777).
15
19
  */
16
20
  const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
17
21
  export function parseNamedArgs(args, options) {
@@ -23,12 +27,15 @@ export function parseNamedArgs(args, options) {
23
27
  const positionals = [];
24
28
  for (let index = 0; index < args.length; index += 1) {
25
29
  const arg = args[index] ?? "";
26
- rejectServiceRoleLikeArgument(arg);
30
+ rejectSupabaseServiceCredential(arg);
27
31
  if (!arg.startsWith("--")) {
28
32
  positionals.push(arg);
29
33
  continue;
30
34
  }
31
- const [flag, inlineValue] = arg.split("=", 2);
35
+ // Split on the FIRST "=" only: the value may hold more of them ("ARR = 1M", a URL query).
36
+ const equalsAt = arg.indexOf("=");
37
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
38
+ const inlineValue = equalsAt === -1 ? undefined : arg.slice(equalsAt + 1);
32
39
  if (!allowed.has(flag))
33
40
  throw new Error(`Unknown flag: ${flag}`);
34
41
  if (valueFlags.has(flag)) {
@@ -36,7 +43,7 @@ export function parseNamedArgs(args, options) {
36
43
  if (!value || value.startsWith("--")) {
37
44
  throw new Error(`${flag} requires a value.`);
38
45
  }
39
- rejectServiceRoleLikeArgument(value);
46
+ rejectSupabaseServiceCredential(value);
40
47
  flags.set(flag, value);
41
48
  const existing = flagValues.get(flag) ?? [];
42
49
  existing.push(value);
@@ -139,14 +146,84 @@ export function normalizeUrl(value) {
139
146
  throw new Error("URL value cannot be empty.");
140
147
  return trimmed;
141
148
  }
142
- function rejectServiceRoleLikeArgument(value) {
143
- if (!looksLikeServiceRoleSecret(value))
144
- return;
145
- throw new Error("Service-role credentials are not accepted by local collector commands.");
149
+ /**
150
+ * Supabase's newer secret key format (`SUPABASE_SECRET_KEY`, admin-contracts.ts): not a JWT, so it needs its own shape.
151
+ * A provider prefix plus a key-length body: prose about the key ("SUPABASE_SECRET_KEY docs") never matches.
152
+ */
153
+ const SUPABASE_SECRET_KEY_SHAPE = /\bsb_secret_[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*/;
154
+ /**
155
+ * Every command, every token: refuses a Supabase service credential, by SHAPE: a JWT whose payload says it is the
156
+ * privileged role, or an `sb_secret_…` key. The words "service-role key" are plain text (BLI-5777: a meeting note that
157
+ * named one was refused, and an agent reworded the client's words to get through).
158
+ */
159
+ export function rejectSupabaseServiceCredential(value) {
160
+ if (looksLikeServiceRoleJwt(value) || containsServiceRoleJwt(value) || SUPABASE_SECRET_KEY_SHAPE.test(value)) {
161
+ throw new Error("Service-role credentials are not accepted by local collector commands.");
162
+ }
163
+ }
164
+ /**
165
+ * The key SHAPES `cockpit manager` free text is refused for: only high-precision ones, each a provider's own key format, so a
166
+ * sentence about a key, a commit sha or a session name is never refused. Copied from the telemetry-core rulebook
167
+ * (`packages/cockpit-telemetry-core/src/secret-guards-content-rules.ts`, `SECRET_VALUE_PATTERNS` and the password rule), which is
168
+ * built to over-match for redaction (it masks and uploads, never refuses) and is NOT used here: its mem0 rule `m0[-_]…` reads
169
+ * "M0-deliverable-review-session" as a key. Left out on purpose: that rule, `name=value` assignments other than a password, and
170
+ * any generic entropy, token or session-id shape.
171
+ */
172
+ export const MANAGER_KEY_SHAPES = [
173
+ // Any JWT (a Supabase anon, service-role or user token): three base64url parts, the first starting `eyJ`.
174
+ /\beyJ[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\b/,
175
+ // Real keys are long and hold digits; prose ("Upgrade sk-learn-pipeline-improvements", "rk_quarterly_review_notes_final") does not: a digit is required,
176
+ // and sk- needs a 32+ character body, rk_ and the _live_/_test_ keys 24+. (Not pk_live_: a publishable key is public.)
177
+ /\bsk-(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}/,
178
+ /\b(?:sk|rk)_(?:live|test)_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{24,}/,
179
+ /\brk_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{24,}/,
180
+ SUPABASE_SECRET_KEY_SHAPE,
181
+ /\bgh[pousr]_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{16,}/,
182
+ /\bgithub_pat_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{20,}/,
183
+ /\blin_api_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{16,}/,
184
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
185
+ /\bxox[abprs]-(?=[A-Za-z0-9-]*\d)[A-Za-z0-9-]{16,}/,
186
+ /\bBearer\s+(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{16,}/,
187
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
188
+ ];
189
+ /** A candidate password: the word, then `:` or `=` and a value (its quotes aside). */
190
+ const PASSWORD_ASSIGNMENT = /(?<![A-Za-z0-9])(?:password|passwd|passcode)(?:[_-][A-Za-z0-9_-]{0,40})?["']?\s*[:=]\s*["']?([^\s"'`,;<>(){}\[\]]{6,})/gi;
191
+ /**
192
+ * A password ASSIGNED a value is a secret when the value holds a digit or a symbol ("password: hunter2", `passwd="x1y2z3"`, "admin password: Tr0ub4dor&3").
193
+ * Prose is text: the word alone ("password reset flow"), a value of letters ("Password: rotated."), sentence punctuation after it (".,!?;:"), a hyphen or
194
+ * apostrophe between letters ("password: on-site", "Client password: e-mailed to Jo") and the product name 1Password ("Shared password: 1Password vault").
195
+ */
196
+ export function looksLikePasswordAssignment(value) {
197
+ for (const match of value.matchAll(PASSWORD_ASSIGNMENT)) {
198
+ const plain = match[1].replace(/[.,!?;:]+$/, "").replace(/1password/gi, "").replace(/(?<=[A-Za-z])[-'’](?=[A-Za-z])/g, "");
199
+ if (/[^A-Za-z]/.test(plain))
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+ /** The whole manager free-text question: does this text hold a key shape or an assigned password? */
205
+ export function looksLikeManagerSecret(value) {
206
+ return MANAGER_KEY_SHAPES.some((shape) => shape.test(value)) || looksLikePasswordAssignment(value);
207
+ }
208
+ /**
209
+ * `cockpit manager` free text only (its text flags and every `--fields-file` value): the service-role JWT, plus the key shapes above.
210
+ * `where` names the flag or file key, so the sentence says which one; the value is never echoed.
211
+ */
212
+ export function rejectSecretLikeValue(value, where) {
213
+ // The named sentence first (KEY_SHAPES holds the JWT and sb_secret_ shapes too); the service-role JWT check stays as the floor.
214
+ if (looksLikeManagerSecret(value)) {
215
+ throw new Error(`The value of ${where} looks like a secret (a key, token or password), and secrets are not accepted there. Take the secret out and run it again; text that only talks about a key is fine.`);
216
+ }
217
+ rejectSupabaseServiceCredential(value);
146
218
  }
147
- function looksLikeServiceRoleSecret(value) {
148
- if (serviceCredentialNamePattern().test(value))
149
- return true;
219
+ /**
220
+ * A service-role JWT anywhere inside the value ("the service-role key=<jwt> for staging."): every token that starts at a JWT header (`eyJ`, which every
221
+ * Supabase JWT does) is decoded. Anchored on the header, not on any three dotted words, so a JWT glued after a dotted word ("staging.eyJ….….…") is found whole.
222
+ */
223
+ function containsServiceRoleJwt(value) {
224
+ return (value.match(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) ?? []).some((token) => looksLikeServiceRoleJwt(token));
225
+ }
226
+ function looksLikeServiceRoleJwt(value) {
150
227
  const parts = value.split(".");
151
228
  if (parts.length !== 3)
152
229
  return false;
@@ -162,12 +239,6 @@ function looksLikeServiceRoleSecret(value) {
162
239
  return false;
163
240
  }
164
241
  }
165
- function serviceCredentialNamePattern() {
166
- return new RegExp([
167
- ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
168
- ["service", "role"].join("[_-]?"),
169
- ].join("|"), "i");
170
- }
171
242
  function serviceCredentialPayloadPattern() {
172
243
  const privilegedRole = ["service", "role"].join("_");
173
244
  return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
22
  const CAL_ACTIONS = new Set([
23
- "event", "export", "duplicate", "batch", "undo", "conflicts", "calendar-change",
23
+ "event", "export", "duplicate", "batch", "undo", "conflicts", "calendar-change", "view-settings", "view-settings-change",
24
24
  "today",
25
25
  "week",
26
26
  "next",
@@ -72,6 +72,7 @@ const CAL_ALLOWED_FLAGS = [
72
72
  "--expected-version",
73
73
  "--reason",
74
74
  "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
75
+ "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
75
76
  ];
76
77
  const CAL_VALUE_FLAGS = [
77
78
  "--home",
@@ -96,6 +97,7 @@ const CAL_VALUE_FLAGS = [
96
97
  "--expected-version",
97
98
  "--reason",
98
99
  "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
100
+ "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
99
101
  ];
100
102
  export function parseCalArgs(args) {
101
103
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -109,13 +111,13 @@ export function parseCalArgs(args) {
109
111
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
110
112
  const offset = readCalOffset(values);
111
113
  const command = buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
112
- return { ...command, ...readCalEventScope(action, values) };
114
+ return { ...command, ...readCalEventScope(action, values), ...readViewSettingsPatch(action, values) };
113
115
  }
114
116
  function readCalAction(values) {
115
117
  const first = values.positionals[0];
116
118
  const action = (first === undefined ? "today" : first);
117
119
  if (!CAL_ACTIONS.has(action)) {
118
- throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, next, find, calendars, add-ical, create, duplicate, batch, undo, share, detach, sync, sync-status, or delete.`);
120
+ throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, view-settings, view-settings-change, next, find, calendars, add-ical, create, duplicate, batch, undo, share, detach, sync, sync-status, or delete.`);
119
121
  }
120
122
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
121
123
  }
@@ -321,4 +323,51 @@ function readReminders(raw) {
321
323
  throw new Error("A reminder method is popup or email.");
322
324
  return { minutes, ...(method ? { method } : {}) };
323
325
  });
326
+ }
327
+ const VIEW_FLAG_KEYS = ["--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone"];
328
+ /**
329
+ * BLI-21423 `view-settings-change`: the flags become the same partial patch the
330
+ * door takes. Types are read here; the door owns the ranges and the zone list.
331
+ */
332
+ function readViewSettingsPatch(action, values) {
333
+ if (action !== "view-settings-change") {
334
+ for (const flag of VIEW_FLAG_KEYS) {
335
+ if (values.flags.has(flag))
336
+ throw new Error(`cal ${action} does not take ${flag}; it belongs to cal view-settings-change.`);
337
+ }
338
+ return {};
339
+ }
340
+ const patch = {};
341
+ const view = optionalNonEmpty(values.flags.get("--view"));
342
+ if (view !== undefined)
343
+ patch.default_view = view;
344
+ const days = optionalNonEmpty(values.flags.get("--days"));
345
+ if (days !== undefined) {
346
+ if (!/^\d+$/.test(days))
347
+ throw new Error("--days takes a whole number from 2 to 9.");
348
+ patch.day_count = Number(days);
349
+ }
350
+ const weekStart = optionalNonEmpty(values.flags.get("--week-start"));
351
+ if (weekStart !== undefined)
352
+ patch.week_start = weekStart;
353
+ const hide = optionalNonEmpty(values.flags.get("--hide-weekends"));
354
+ if (hide !== undefined) {
355
+ if (hide !== "on" && hide !== "off")
356
+ throw new Error("--hide-weekends takes on or off.");
357
+ patch.hide_weekends = hide === "on";
358
+ }
359
+ const density = optionalNonEmpty(values.flags.get("--density"));
360
+ if (density !== undefined)
361
+ patch.density = density;
362
+ const zones = values.flagValues.get("--zone") ?? [];
363
+ if (zones.length) {
364
+ if (zones.includes("none")) {
365
+ if (zones.length > 1)
366
+ throw new Error("--zone none stands alone: it clears the extra zones.");
367
+ patch.extra_zones = [];
368
+ }
369
+ else
370
+ patch.extra_zones = zones;
371
+ }
372
+ return Object.keys(patch).length ? { viewSettings: patch } : {};
324
373
  }
@@ -4,27 +4,33 @@
4
4
  * cockpit manager clients
5
5
  * cockpit manager client <client>
6
6
  * cockpit manager home <client>
7
- * cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <t>] [--bullet "YYYY-MM-DD: t"|"t"]... [--next <t>|none] [--blocker <t>|none]
7
+ * cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <t>] [--bullet 'YYYY-MM-DD: t'|'t']... [--next <t>|none] [--blocker <t>|none]
8
8
  * cockpit manager run <run-id>
9
9
  * 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]
10
10
  * cockpit manager queue <client>
11
11
  * cockpit manager show <id> [--timeline]
12
- * cockpit manager add <client> --title <t> [--owners "A, B"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
12
+ * cockpit manager add <client> --title <t> [--owners 'A, B'] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
13
13
  * cockpit manager add <client> --timeline --kind <kind> --title <t> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
14
- * cockpit manager edit <id> --version <v> [--timeline] [--title] [--detail] [--due <date>|none] [--owners "A, B"|none]
14
+ * cockpit manager edit <id> --version <v> [--timeline] [--title] [--detail] [--due <date>|none] [--owners 'A, B'|none]
15
15
  * (timeline: --kind, --date <date>|none, --owner <one name>|none)
16
- * cockpit manager done|reopen <action> --version <v>
16
+ * cockpit manager done|drop|reopen <action> --version <v>
17
17
  * cockpit manager link <action> --version <v> --meeting <timeline-id> | --none
18
18
  * cockpit manager approve <id> --version <v> [--timeline] [--restore] (--restore brings a dismissed item back)
19
19
  * cockpit manager dismiss|archive|unarchive <id> --version <v> [--timeline]
20
20
  * cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline]
21
21
  *
22
+ * Free text goes in a JSON file or on stdin with `--fields-file <path>|-` on add, edit and summary (no shell quoting at all,
23
+ * `manager-fields-file.ts`); on a flag it goes in SINGLE quotes (the shell leaves `$` alone) and the help says how to write
24
+ * an apostrophe. The parser is synchronous and a file is not: `--fields-file` parses to a command that carries `fieldsFile`
25
+ * and `rawArgs`, and `runManager` reads the file and parses the same args again with its text (`options.fieldsText`).
22
26
  * Every verb is one `/api/manager` door on this machine's device token. A bare `cockpit manager`,
23
27
  * `help` and `--help` are the help, never a list and never another command.
24
28
  */
25
- import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
29
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs, rejectSecretLikeValue } from "./local-arg-values.js";
30
+ import { mergeFieldsFile } from "./manager-fields-file.js";
31
+ import { runIdRefusal } from "./manager-hints.js";
26
32
  export const MANAGER_VERBS = [
27
- "clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "edit", "done", "reopen", "link",
33
+ "clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "edit", "done", "drop", "reopen", "link",
28
34
  "approve", "dismiss", "proposal", "archive", "unarchive",
29
35
  ];
30
36
  export const MANAGER_ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
@@ -38,14 +44,16 @@ const ONE_VERB = `manager takes one verb: ${MANAGER_VERBS.join(", ")}. Example:
38
44
  const OWN_FLAGS = [
39
45
  "--timeline", "--needs-review", "--status", "--owner", "--owners", "--no-owner", "--kind", "--search", "--archived", "--review",
40
46
  "--title", "--detail", "--due", "--date", "--meeting", "--none", "--version", "--request-id", "--restore", "--overdue", "--source-url", "--source-note",
41
- "--health", "--headline", "--bullet", "--next", "--blocker",
47
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
42
48
  ];
43
49
  const COMMON_FLAGS = ["--home", "--dashboard-url", "--json", "--help"];
44
50
  const VALUE_FLAGS = [
45
51
  "--status", "--owner", "--owners", "--kind", "--search", "--review", "--title", "--detail", "--due", "--date", "--meeting",
46
52
  "--version", "--request-id", "--source-url", "--source-note", "--home", "--dashboard-url",
47
- "--health", "--headline", "--bullet", "--next", "--blocker",
53
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
48
54
  ];
55
+ /** The flags whose value is a person's own words (not an id, a version or a choice). */
56
+ const TEXT_FLAGS = ["--title", "--detail", "--headline", "--bullet", "--next", "--blocker", "--owners", "--owner", "--source-note", "--source-url"];
49
57
  /** `summary --bullet` is given once per bullet: the one value flag that may repeat. */
50
58
  const REPEATABLE_FLAGS = new Set(["--bullet"]);
51
59
  /** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
@@ -53,14 +61,15 @@ export const FLAGS_BY_VERB = {
53
61
  clients: [],
54
62
  client: [],
55
63
  home: [],
56
- summary: ["--version", "--health", "--headline", "--bullet", "--next", "--blocker"],
64
+ summary: ["--version", "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file"],
57
65
  run: [],
58
66
  list: ["--timeline", "--needs-review", "--status", "--owner", "--no-owner", "--overdue", "--kind", "--search", "--archived", "--review"],
59
67
  queue: [],
60
68
  show: ["--timeline"],
61
- add: ["--timeline", "--title", "--detail", "--owners", "--owner", "--due", "--meeting", "--kind", "--date", "--source-url", "--source-note", "--request-id"],
62
- edit: ["--timeline", "--version", "--title", "--detail", "--owners", "--owner", "--due", "--kind", "--date"],
69
+ add: ["--timeline", "--title", "--detail", "--owners", "--owner", "--due", "--meeting", "--kind", "--date", "--source-url", "--source-note", "--request-id", "--fields-file"],
70
+ edit: ["--timeline", "--version", "--title", "--detail", "--owners", "--owner", "--due", "--kind", "--date", "--fields-file"],
63
71
  done: ["--version"],
72
+ drop: ["--version"],
64
73
  reopen: ["--version"],
65
74
  link: ["--version", "--meeting", "--none"],
66
75
  approve: ["--timeline", "--version", "--restore"],
@@ -82,6 +91,7 @@ const POSITIONALS = {
82
91
  add: ["client"],
83
92
  edit: ["id"],
84
93
  done: ["action id"],
94
+ drop: ["action id"],
85
95
  reopen: ["action id"],
86
96
  link: ["action id"],
87
97
  approve: ["id"],
@@ -103,9 +113,13 @@ function refuseRepeatedFlags(args) {
103
113
  }
104
114
  }
105
115
  }
106
- export function parseManagerArgs(args) {
116
+ export function parseManagerArgs(args, options = {}) {
107
117
  refuseRepeatedFlags(args);
108
118
  const values = parseNamedArgs(args, { allowedFlags: [...OWN_FLAGS, ...COMMON_FLAGS], valueFlags: VALUE_FLAGS });
119
+ // Free text is the one place a key could be pasted by mistake: the wider secret rulebook runs on these flags (and on a fields file's values), no other command's.
120
+ for (const flag of TEXT_FLAGS)
121
+ for (const value of values.flagValues.get(flag) ?? [])
122
+ rejectSecretLikeValue(value, flag);
109
123
  const base = {
110
124
  kind: "manager",
111
125
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -136,9 +150,9 @@ export function parseManagerArgs(args) {
136
150
  if (CLIENT_VERBS.has(verb))
137
151
  shape.client = rest[0];
138
152
  else if (verb === "run")
139
- shape.runId = readId(rest[0], "A run");
153
+ shape.runId = readRunId(rest[0]);
140
154
  else if (verb !== "clients")
141
- shape.id = readId(rest[0], verb === "done" || verb === "reopen" || verb === "link" ? "An action" : "An item");
155
+ shape.id = readId(rest[0], verb === "done" || verb === "drop" || verb === "reopen" || verb === "link" ? "An action" : "An item");
142
156
  if (verb === "proposal") {
143
157
  shape.proposalId = rest[1];
144
158
  shape.decision = readDecision(rest[2]);
@@ -146,8 +160,16 @@ export function parseManagerArgs(args) {
146
160
  const needsVersion = !["clients", "client", "home", "summary", "run", "list", "queue", "show", "add"].includes(verb);
147
161
  if (needsVersion)
148
162
  shape.version = readVersion(flag("--version"), verb);
163
+ let fileBullets;
164
+ const fieldsFile = flag("--fields-file");
165
+ if (fieldsFile !== undefined && (verb === "add" || verb === "edit" || verb === "summary")) {
166
+ // Not read yet: `runManager` reads the file (or stdin) and parses these same args again with its text.
167
+ if (options.fieldsText === undefined)
168
+ return { ...shape, fieldsFile, rawArgs: args };
169
+ fileBullets = mergeFieldsFile(values, verb, options.fieldsText).bullets;
170
+ }
149
171
  if (verb === "summary")
150
- readSummaryFields(values, shape);
172
+ readSummaryFields(values, shape, fileBullets);
151
173
  if (verb === "list")
152
174
  readListFilters(values, shape);
153
175
  if (verb === "add")
@@ -160,6 +182,12 @@ export function parseManagerArgs(args) {
160
182
  shape.restore = true;
161
183
  return shape;
162
184
  }
185
+ /** A run's receipt id: a uuid. Anything else is refused with where run ids really come from (a read's workflow id, `sched_wf_…`, is named for what it is). */
186
+ function readRunId(value) {
187
+ if (!ID.test(value))
188
+ throw new Error(runIdRefusal(value));
189
+ return value;
190
+ }
163
191
  function readId(value, what) {
164
192
  if (!ID.test(value))
165
193
  throw new Error(`${what} id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10 (\`cockpit manager list <client>\` prints them).`);
@@ -253,13 +281,24 @@ function refuseWrongItemFlags(values, shape) {
253
281
  }
254
282
  else {
255
283
  if (given("--owner"))
256
- throw new Error("An action has several owners: use --owners \"A, B\" (or --owners none). --owner is for a timeline item.");
284
+ throw new Error("An action has several owners: use --owners 'A, B' (or --owners none). --owner is for a timeline item.");
257
285
  const item = ["--kind", "--date"].find(given);
258
286
  if (item)
259
287
  throw new Error(`${item} belongs to a timeline item. Add --timeline, or use --due for an action's date.`);
260
288
  }
261
289
  }
290
+ /** A day written with a month name at the start of a bullet: "Oct 3:", "Sat, Oct 3rd, 2026:", "3 October:". */
291
+ const MONTH = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?";
292
+ const MONTH_NAME_DAY = new RegExp(`^((?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\\.?,?\\s+)?(?:${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?|\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH})(?:,?\\s+\\d{4})?)\\s*:`, "i");
262
293
  const isNone = (value) => value.trim().toLowerCase() === "none";
294
+ /**
295
+ * Nothing a person can read: empty, whitespace, or only invisible characters, the same set the server's "Where we are"
296
+ * blank test reads as invisible (`summary-answer.ts` INVISIBLE: default-ignorable code points, format characters,
297
+ * combining marks such as U+FE0F and U+034F, the Hangul fillers and the blank braille U+2800), plus control characters.
298
+ * `trim()` alone leaves U+FE0F and U+200B behind, so free text that looks empty used to be sent (and, as --next, cleared
299
+ * the field while the terminal said "Saved").
300
+ */
301
+ const isBlank = (value) => /^[\s\p{Z}\p{Cc}\p{Default_Ignorable_Code_Point}\p{Cf}\p{M}\u115F\u1160\u3164\uFFA0\u2800]*$/u.test(value);
263
302
  /** `--owners "A, B"` replaces the whole list; `none` is the only way to clear it; a list of blanks is neither. */
264
303
  function readOwnersList(raw) {
265
304
  if (isNone(raw))
@@ -282,7 +321,11 @@ function readOneOwner(raw) {
282
321
  }
283
322
  function readAddFields(values, shape) {
284
323
  refuseWrongItemFlags(values, shape);
285
- const title = optionalNonEmpty(values.flags.get("--title"));
324
+ const raw = values.flags.get("--title");
325
+ // A title of only invisible characters (U+200B, U+FE0F, U+3164 …) shows nothing: refused as blank, like edit's.
326
+ if (raw !== undefined && raw.trim() !== "" && isBlank(raw))
327
+ throw new Error("--title cannot be blank.");
328
+ const title = optionalNonEmpty(raw);
286
329
  if (!title)
287
330
  throw new Error("`cockpit manager add` needs --title <text>.");
288
331
  shape.title = title;
@@ -338,7 +381,7 @@ function readEditFields(values, shape) {
338
381
  let changes = 0;
339
382
  const title = values.flags.get("--title");
340
383
  if (title !== undefined) {
341
- if (title.trim() === "")
384
+ if (isBlank(title))
342
385
  throw new Error("--title cannot be blank.");
343
386
  shape.title = title.trim();
344
387
  changes += 1;
@@ -397,24 +440,69 @@ function readSummaryVersion(raw) {
397
440
  throw new Error("--version is the card's summary.updatedAt exactly as `cockpit manager home <client> --json` gives it, like 2026-10-09T17:03:11.123456+00:00, or none.");
398
441
  return raw;
399
442
  }
400
- /** `--bullet "2026-10-14: Signed the SOW"` is a dated bullet; without a leading `YYYY-MM-DD: ` it is undated. */
443
+ /**
444
+ * The card's text limits, in characters (code points), as the help and the server's `TOWER_MANAGER_SUMMARY_LIMITS` say
445
+ * (`manager-cli-limits.test.ts` in the dashboard fails when they differ). Checked here so a long text is refused by this
446
+ * sentence, not by the server's raw schema message ("String must contain at most 4000 character(s)") that a text over 4,000 reaches first.
447
+ */
448
+ export const SUMMARY_TEXT_LIMITS = { headline: 140, bullet: 160, next: 200, blocker: 200 };
449
+ function refuseTooLong(flag, text, max) {
450
+ const length = [...text].length;
451
+ if (length > max)
452
+ throw new Error(`${flag} is ${length.toLocaleString("en-US")} characters; at most ${max}. Shorten it.`);
453
+ }
454
+ /** `--bullet '2026-10-14: Signed the SOW'` is a dated bullet; without a leading `YYYY-MM-DD: ` it is undated. */
401
455
  export function readBullet(raw) {
402
456
  // A bullet pasted from a list keeps no "- " or "* " of its own.
403
457
  const value = raw.trim().replace(/^[-*•]\s+/u, "");
404
- const dated = /^(\d{4}-\d{2}-\d{2}):\s*(.*)$/s.exec(value);
405
- const dateLike = dated ? null : /^(\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4})\s*:/.exec(value);
458
+ const dated = /^(\d{4}-\d{2}-\d{2})\s*:\s*(.*)$/s.exec(value);
459
+ // A shape of digits split by dashes or slashes was always read as a mistyped day ("26-10-01:", "10/14/2026:",
460
+ // "2026-10/14:"). A DOT only counts with a 4-digit year: first and then dash/slash or dot ("2026-10.14:",
461
+ // "2026.10.14:"), or last in a dotted-only shape ("14.10.2026:"). Short ranges ("1-2.5:", "9-10.30:") and
462
+ // versions ("2.1.0:") are plain text.
463
+ const dateLike = dated ? null : /^(\d{1,4}[-/]\d{1,2}[-/]\d{1,4}|\d{4}[-/]\d{1,2}\.\d{1,4}|\d{4}\.\d{1,2}\.\d{1,2}|\d{1,2}\.\d{1,2}\.\d{4})\s*:/.exec(value);
406
464
  if (dateLike)
407
465
  throw new Error(`--bullet "${raw}" starts with ${dateLike[1]}, which is not a YYYY-MM-DD day. Write it like 2026-10-14, or leave the date out for an undated bullet.`);
466
+ // A day spelled with a month name ("Oct 3: x", "3 October 2026: x") is a date too: saved undated it would lose the day without a word.
467
+ const worded = dated ? null : MONTH_NAME_DAY.exec(value);
468
+ if (worded)
469
+ throw new Error(`--bullet "${raw}" starts with ${worded[1].trim()}, which looks like a date but is not YYYY-MM-DD. Write it like 2026-10-14 to date the bullet, or reword the text so it does not start with a date.`);
408
470
  const date = dated ? dated[1] : null;
409
471
  const text = (dated ? dated[2] : value).trim();
410
472
  if (date !== null && !isCalendarDate(date))
411
473
  throw new Error(`--bullet "${raw}" starts with ${date}, which is not a real day. Write the date YYYY-MM-DD, like 2026-10-14, or leave it out for an undated bullet.`);
412
- if (text === "")
413
- throw new Error("--bullet needs words after its date. Example: --bullet \"2026-10-14: Signed the SOW\".");
474
+ return checkedBullet(date, text);
475
+ }
476
+ /** A bullet's words are never blank (the same rule for `--bullet` and a fields file's `{date, text}`). */
477
+ function checkedBullet(date, text) {
478
+ if (isBlank(text)) {
479
+ throw new Error(date === null
480
+ ? "--bullet needs some words, and this one is empty. Example: --bullet 'Export still missing' (a day first makes it dated: --bullet '2026-10-14: Signed the SOW')."
481
+ : "--bullet needs words after its date. Example: --bullet '2026-10-14: Signed the SOW'.");
482
+ }
414
483
  return { date, text };
415
484
  }
485
+ /**
486
+ * A fields file's `{date, text}` bullet. With a date, the date must be a real day and the text is the text. With none (null or
487
+ * left out), the text is read exactly like a `--bullet` value: a leading "YYYY-MM-DD: " dates it, a day spelled any other way is
488
+ * refused, never saved undated (the same rule MCP's manager_set_summary applies).
489
+ */
490
+ function readObjectBullet(bullet) {
491
+ if (bullet.date === null)
492
+ return readBullet(bullet.text);
493
+ if (!isCalendarDate(bullet.date)) {
494
+ throw new Error(`A bullet's date "${bullet.date}" is not a real day. Write the date YYYY-MM-DD, like 2026-10-14, or null for an undated bullet.`);
495
+ }
496
+ return checkedBullet(bullet.date, bullet.text.trim());
497
+ }
498
+ /** --next and --blocker: text, or `none` to clear. A value with nothing readable in it is refused, never sent as a silent clear. */
499
+ function readClearable(flag, raw) {
500
+ if (isBlank(raw))
501
+ throw new Error(`${flag} is blank, and a blank would clear it. To clear ${flag} write ${flag} none; otherwise give words.`);
502
+ return raw.trim();
503
+ }
416
504
  /** The card's fields. Counts, lengths and "blocked needs a blocker" are the server's rules, refused there by name. */
417
- function readSummaryFields(values, shape) {
505
+ function readSummaryFields(values, shape, fileBullets) {
418
506
  shape.version = readSummaryVersion(values.flags.get("--version"));
419
507
  let changes = 0;
420
508
  const health = values.flags.get("--health");
@@ -426,26 +514,38 @@ function readSummaryFields(values, shape) {
426
514
  }
427
515
  const headline = values.flags.get("--headline");
428
516
  if (headline !== undefined) {
429
- if (headline.trim() === "")
517
+ if (isBlank(headline))
430
518
  throw new Error("--headline cannot be blank.");
519
+ refuseTooLong("--headline", headline.trim(), SUMMARY_TEXT_LIMITS.headline);
431
520
  shape.headline = headline.trim();
432
521
  changes += 1;
433
522
  }
434
523
  const bullets = values.flagValues.get("--bullet");
435
- if (bullets !== undefined && bullets.length > 0) {
524
+ if (fileBullets !== undefined) {
525
+ // An empty list is a mistake, not "no change": the server wants 1 to 5 and refuses 0 by name.
526
+ shape.bullets = fileBullets.map((bullet) => (typeof bullet === "string" ? readBullet(bullet) : readObjectBullet(bullet)));
527
+ changes += 1;
528
+ }
529
+ else if (bullets !== undefined && bullets.length > 0) {
436
530
  shape.bullets = bullets.map(readBullet);
437
531
  changes += 1;
438
532
  }
533
+ for (const bullet of shape.bullets ?? [])
534
+ refuseTooLong("A bullet", bullet.text, SUMMARY_TEXT_LIMITS.bullet);
439
535
  const next = values.flags.get("--next");
440
536
  if (next !== undefined) {
441
- shape.next = isNone(next) ? null : next.trim();
537
+ shape.next = isNone(next) ? null : readClearable("--next", next);
442
538
  changes += 1;
443
539
  }
444
540
  const blocker = values.flags.get("--blocker");
445
541
  if (blocker !== undefined) {
446
- shape.blocker = isNone(blocker) ? null : blocker.trim();
542
+ shape.blocker = isNone(blocker) ? null : readClearable("--blocker", blocker);
447
543
  changes += 1;
448
544
  }
545
+ if (shape.next)
546
+ refuseTooLong("--next", shape.next, SUMMARY_TEXT_LIMITS.next);
547
+ if (shape.blocker)
548
+ refuseTooLong("--blocker", shape.blocker, SUMMARY_TEXT_LIMITS.blocker);
449
549
  if (changes === 0) {
450
550
  throw new Error("`cockpit manager summary` has nothing to change. Give at least one of --health, --headline, --bullet (1 to 5 of them, replacing the list), --next, --blocker. A flag you leave out keeps its value; none clears --next or --blocker.");
451
551
  }