@bli-cockpit/cli 0.2.48 → 0.2.49

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 (39) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/jarvis.js +179 -3
  18. package/dist/commands/local-arg-values.js +169 -0
  19. package/dist/commands/local-args-collector.js +578 -0
  20. package/dist/commands/local-args-tower.js +870 -0
  21. package/dist/commands/local-args.js +8 -1549
  22. package/dist/commands/local-help.js +11 -3
  23. package/dist/commands/local.js +18 -1786
  24. package/dist/commands/login.js +53 -0
  25. package/dist/commands/logout.js +66 -0
  26. package/dist/commands/onboard-receipts.js +66 -0
  27. package/dist/commands/onboard-report.js +274 -0
  28. package/dist/commands/onboard.js +449 -0
  29. package/dist/commands/ops-render.js +36 -0
  30. package/dist/commands/public-root.js +1 -1
  31. package/dist/commands/serve.js +13 -0
  32. package/dist/commands/session-sync.js +513 -534
  33. package/dist/commands/settings-render.js +28 -0
  34. package/dist/commands/settings.js +66 -2
  35. package/dist/commands/start.js +47 -0
  36. package/dist/commands/sync-followups.js +203 -0
  37. package/dist/commands/sync.js +381 -0
  38. package/dist/tower-stream.js +20 -4
  39. package/package.json +1 -1
@@ -1,12 +1,11 @@
1
- // CLI argument parsing for the local cockpit collector. Split out of
2
- // commands/local.ts so the command runners read as a table of contents and
3
- // the parser (pure, independently testable) can change at its own rate.
4
- //
5
- // Behavior-preserving extraction: functions moved verbatim, no logic change.
6
- import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
7
- import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
8
- import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
9
- const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
1
+ import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
2
+ import { parseBriefArgs, parseCorrectArgs, parseJarvisArgs, parseModelArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
3
+ // `normalizeUrl` has always been part of this module's surface `local.ts` and
4
+ // `local-auth.ts` import it from here — so it stays exported from this address
5
+ // even though it now lives next door. The same goes for the four names the
6
+ // Tower parsers publish.
7
+ export { normalizeUrl } from "./local-arg-values.js";
8
+ export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, } from "./local-args-tower.js";
10
9
  // The six human "set my machine up" doors. They are one thing wearing six
11
10
  // hats, so they all run the convergence command — but they keep accepting the
12
11
  // flags they always accepted, because DMs, runbooks and AGENTS.md rules across
@@ -86,1544 +85,4 @@ export function parseLocalArgs(argv) {
86
85
  default:
87
86
  throw new Error(`Unknown local command: ${command ?? ""}`);
88
87
  }
89
- }
90
- function parseOnboardLikeArgs(args, command) {
91
- const values = parseNamedArgs(args, {
92
- allowedFlags: [
93
- "--home",
94
- "--repo",
95
- "--workspace",
96
- "--dashboard-url",
97
- "--email",
98
- "--device-name",
99
- "--ticket",
100
- "--branch",
101
- "--json",
102
- "--no-auth",
103
- "--poll-interval-ms",
104
- "--timeout-ms",
105
- "--max-depth",
106
- "--max-repos",
107
- "--allow-home-root",
108
- ],
109
- valueFlags: [
110
- "--home",
111
- "--repo",
112
- "--workspace",
113
- "--dashboard-url",
114
- "--email",
115
- "--device-name",
116
- "--ticket",
117
- "--branch",
118
- "--poll-interval-ms",
119
- "--timeout-ms",
120
- "--max-depth",
121
- "--max-repos",
122
- ],
123
- });
124
- assertNoPositionals(values.positionals, command);
125
- return {
126
- homeDir: optionalNonEmpty(values.flags.get("--home")),
127
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
128
- collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
129
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
130
- dashboardUrlExplicit: values.flags.has("--dashboard-url"),
131
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
132
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
133
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
134
- branch: optionalNonEmpty(values.flags.get("--branch")),
135
- noAuth: values.booleans.has("--no-auth"),
136
- json: values.booleans.has("--json"),
137
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
138
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
139
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
140
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
141
- allowHomeRoot: values.booleans.has("--allow-home-root"),
142
- };
143
- }
144
- function parseOnboardArgs(args) {
145
- return { kind: "onboard", ...parseOnboardLikeArgs(args, "onboard") };
146
- }
147
- function parseUpdateArgs(alias, args) {
148
- return {
149
- kind: "update",
150
- alias,
151
- ...parseOnboardLikeArgs(args, alias),
152
- };
153
- }
154
- function parseDoctorArgs(alias, args) {
155
- // The allowed set is the UNION of what the six setup doors used to accept.
156
- // An alias that silently rejected a flag its own docs told people to pass
157
- // would be a worse dead end than the one we are removing.
158
- const values = parseNamedArgs(args, {
159
- allowedFlags: [
160
- "--home",
161
- "--repo",
162
- "--workspace",
163
- "--dashboard-url",
164
- "--update-tag",
165
- "--dry-run",
166
- "--json",
167
- "--allow-home-root",
168
- "--max-depth",
169
- "--max-repos",
170
- // Accepted and ignored: the convergence run works these out itself.
171
- // Rejecting them would break existing DMs and runbooks for no gain.
172
- "--email",
173
- "--device-name",
174
- "--ticket",
175
- "--branch",
176
- "--no-auth",
177
- "--poll-interval-ms",
178
- "--timeout-ms",
179
- ],
180
- valueFlags: [
181
- "--home",
182
- "--repo",
183
- "--workspace",
184
- "--dashboard-url",
185
- "--update-tag",
186
- "--max-depth",
187
- "--max-repos",
188
- "--email",
189
- "--device-name",
190
- "--ticket",
191
- "--branch",
192
- "--poll-interval-ms",
193
- "--timeout-ms",
194
- ],
195
- });
196
- assertNoPositionals(values.positionals, alias);
197
- const updateTag = optionalNonEmpty(values.flags.get("--update-tag"));
198
- if (updateTag && !/^[a-z0-9][a-z0-9._-]*$/u.test(updateTag)) {
199
- throw new Error("--update-tag must be a lowercase npm dist-tag such as 'next' or 'latest'.");
200
- }
201
- return {
202
- kind: "doctor",
203
- alias,
204
- homeDir: optionalNonEmpty(values.flags.get("--home")),
205
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
206
- collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
207
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
208
- updateTag,
209
- dryRun: values.booleans.has("--dry-run"),
210
- json: values.booleans.has("--json"),
211
- allowHomeRoot: values.booleans.has("--allow-home-root"),
212
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
213
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
214
- };
215
- }
216
- function parseInstallArgs(args) {
217
- const values = parseNamedArgs(args, {
218
- allowedFlags: [
219
- "--home",
220
- "--repo",
221
- "--workspace",
222
- "--dashboard-url",
223
- "--supabase-url",
224
- "--json",
225
- "--allow-home-root",
226
- ],
227
- valueFlags: [
228
- "--home",
229
- "--repo",
230
- "--workspace",
231
- "--dashboard-url",
232
- "--supabase-url",
233
- ],
234
- });
235
- assertNoPositionals(values.positionals, "install");
236
- return {
237
- kind: "install",
238
- homeDir: optionalNonEmpty(values.flags.get("--home")),
239
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
240
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
241
- supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
242
- json: values.booleans.has("--json"),
243
- allowHomeRoot: values.booleans.has("--allow-home-root"),
244
- };
245
- }
246
- function parseReleaseArgs(args) {
247
- const values = parseNamedArgs(args, {
248
- allowedFlags: [
249
- "--dry-run",
250
- "--tag",
251
- "--access",
252
- "--otp",
253
- "--skip-checks",
254
- ],
255
- valueFlags: ["--tag", "--access", "--otp"],
256
- });
257
- assertNoPositionals(values.positionals, "release");
258
- const releaseArgs = [];
259
- if (values.booleans.has("--dry-run"))
260
- releaseArgs.push("--dry-run");
261
- if (values.booleans.has("--skip-checks"))
262
- releaseArgs.push("--skip-checks");
263
- for (const flag of ["--tag", "--access", "--otp"]) {
264
- const value = values.flags.get(flag);
265
- if (value !== undefined)
266
- releaseArgs.push(flag, value);
267
- }
268
- return { kind: "release", args: releaseArgs };
269
- }
270
- function parseLoginArgs(args) {
271
- const values = parseNamedArgs(args, {
272
- allowedFlags: [
273
- "--home",
274
- "--dashboard-url",
275
- "--email",
276
- "--device-name",
277
- "--json",
278
- "--no-auth",
279
- "--poll-interval-ms",
280
- "--timeout-ms",
281
- ],
282
- valueFlags: [
283
- "--home",
284
- "--dashboard-url",
285
- "--email",
286
- "--device-name",
287
- "--poll-interval-ms",
288
- "--timeout-ms",
289
- ],
290
- });
291
- assertNoPositionals(values.positionals, "login");
292
- return {
293
- kind: "login",
294
- homeDir: optionalNonEmpty(values.flags.get("--home")),
295
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
296
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
297
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
298
- json: values.booleans.has("--json"),
299
- noAuth: values.booleans.has("--no-auth"),
300
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
301
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
302
- };
303
- }
304
- function parseLogoutArgs(args) {
305
- const values = parseNamedArgs(args, {
306
- allowedFlags: ["--home", "--json", "--revoke", "--dashboard-url", "--yes"],
307
- valueFlags: ["--home", "--dashboard-url"],
308
- });
309
- assertNoPositionals(values.positionals, "logout");
310
- return {
311
- kind: "logout",
312
- homeDir: optionalNonEmpty(values.flags.get("--home")),
313
- json: values.booleans.has("--json"),
314
- revoke: values.booleans.has("--revoke"),
315
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
316
- yes: values.booleans.has("--yes"),
317
- };
318
- }
319
- const SETTINGS_SECTIONS = ["personal", "switches", "models", "env"];
320
- /**
321
- * `cockpit settings [section] [verb] [args]` (BLI-3461).
322
- *
323
- * The flag set is the UNION across sections and the combinations are checked
324
- * afterwards, per section — the same shape `parseDoctorArgs` uses. A flag that
325
- * belongs to another section is refused by name rather than silently ignored,
326
- * because a dropped `--project` on an env write is a change a person believes
327
- * they made.
328
- */
329
- function parseSettingsArgs(args) {
330
- const values = parseNamedArgs(args, {
331
- allowedFlags: [
332
- "--home",
333
- "--dashboard-url",
334
- "--json",
335
- "--chat-model",
336
- "--brief-model",
337
- "--chat",
338
- "--memory",
339
- "--project",
340
- "--file",
341
- "--id",
342
- "--content-stdin",
343
- "--yes",
344
- ],
345
- valueFlags: [
346
- "--home",
347
- "--dashboard-url",
348
- "--chat-model",
349
- "--brief-model",
350
- "--chat",
351
- "--memory",
352
- "--project",
353
- "--file",
354
- "--id",
355
- ],
356
- });
357
- const [rawSection, ...rest] = values.positionals;
358
- const common = {
359
- homeDir: optionalNonEmpty(values.flags.get("--home")),
360
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
361
- yes: values.booleans.has("--yes"),
362
- json: values.booleans.has("--json"),
363
- };
364
- if (!rawSection) {
365
- if (rest.length > 0)
366
- throw new Error("settings does not accept that argument.");
367
- return { kind: "settings", section: "overview", action: "show", ...common };
368
- }
369
- if (!SETTINGS_SECTIONS.includes(rawSection)) {
370
- throw new Error(`settings section must be one of ${SETTINGS_SECTIONS.join(", ")} — or nothing, for all of them.`);
371
- }
372
- const section = rawSection;
373
- if (section === "personal") {
374
- if (rest.length > 0)
375
- throw new Error("settings personal does not accept positional arguments.");
376
- const chatModel = optionalNonEmpty(values.flags.get("--chat-model"));
377
- const briefModel = optionalNonEmpty(values.flags.get("--brief-model"));
378
- return {
379
- kind: "settings",
380
- section,
381
- action: chatModel || briefModel ? "set" : "show",
382
- chatModel,
383
- briefModel,
384
- ...common,
385
- };
386
- }
387
- if (section === "switches") {
388
- if (rest.length === 0) {
389
- return { kind: "settings", section, action: "show", ...common };
390
- }
391
- if (rest[0] !== "set") {
392
- throw new Error("settings switches takes no verb, or `set <key> <value>`.");
393
- }
394
- const [, key, value, ...extra] = rest;
395
- if (!key || !value || extra.length > 0) {
396
- throw new Error("settings switches set needs exactly a key and a value.");
397
- }
398
- return { kind: "settings", section, action: "set", switchKey: key, switchValue: value, ...common };
399
- }
400
- if (section === "models") {
401
- if (rest.length === 0) {
402
- return { kind: "settings", section, action: "show", ...common };
403
- }
404
- if (rest[0] !== "set" || rest.length > 1) {
405
- throw new Error("settings models takes no verb, or `set --chat <key>` / `set --memory <id>`.");
406
- }
407
- const orgChatModel = optionalNonEmpty(values.flags.get("--chat"));
408
- const orgMemoryModel = optionalNonEmpty(values.flags.get("--memory"));
409
- if (!orgChatModel && !orgMemoryModel) {
410
- throw new Error("settings models set needs --chat <key>, --memory <id>, or both.");
411
- }
412
- return { kind: "settings", section, action: "set", orgChatModel, orgMemoryModel, ...common };
413
- }
414
- // env
415
- const verb = rest[0] ?? "list";
416
- if (rest.length > 1)
417
- throw new Error("settings env takes one verb: list, set, or delete.");
418
- if (verb === "list") {
419
- return { kind: "settings", section, action: "list", ...common };
420
- }
421
- if (verb === "set") {
422
- const envProject = optionalNonEmpty(values.flags.get("--project"));
423
- const envFile = optionalNonEmpty(values.flags.get("--file"));
424
- if (!envProject || !envFile) {
425
- throw new Error("settings env set needs --project <project> and --file <file name>.");
426
- }
427
- if (!values.booleans.has("--content-stdin")) {
428
- // Deliberate: there is no `--content <value>` flag and never will be. A
429
- // secret on a command line lands in shell history and in every process
430
- // listing on the machine.
431
- throw new Error("settings env set reads the file contents from stdin: add --content-stdin and pipe the file in.");
432
- }
433
- return {
434
- kind: "settings",
435
- section,
436
- action: "set",
437
- envProject,
438
- envFile,
439
- contentStdin: true,
440
- ...common,
441
- };
442
- }
443
- if (verb === "delete") {
444
- const envId = optionalNonEmpty(values.flags.get("--id"));
445
- if (!envId)
446
- throw new Error("settings env delete needs --id <uuid>.");
447
- return { kind: "settings", section, action: "delete", envId, ...common };
448
- }
449
- throw new Error("settings env takes one verb: list, set, or delete.");
450
- }
451
- /** `cockpit team [members|invite <email>|role <userId>]` (BLI-3461). */
452
- function parseTeamArgs(args) {
453
- const values = parseNamedArgs(args, {
454
- allowedFlags: ["--home", "--dashboard-url", "--json", "--role", "--team-id", "--yes"],
455
- valueFlags: ["--home", "--dashboard-url", "--role", "--team-id"],
456
- });
457
- const common = {
458
- homeDir: optionalNonEmpty(values.flags.get("--home")),
459
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
460
- teamId: optionalNonEmpty(values.flags.get("--team-id")),
461
- yes: values.booleans.has("--yes"),
462
- json: values.booleans.has("--json"),
463
- };
464
- const [verb, subject, ...extra] = values.positionals;
465
- if (extra.length > 0)
466
- throw new Error("team does not accept that many arguments.");
467
- if (!verb || verb === "members") {
468
- if (subject)
469
- throw new Error("team members does not accept positional arguments.");
470
- return { kind: "team", action: "members", ...common };
471
- }
472
- if (verb === "invite") {
473
- if (!subject)
474
- throw new Error("team invite needs an email address.");
475
- const email = optionalEmail(subject);
476
- const role = optionalNonEmpty(values.flags.get("--role"));
477
- if (!role)
478
- throw new Error("team invite needs --role <role>.");
479
- return { kind: "team", action: "invite", email, role, ...common };
480
- }
481
- if (verb === "role") {
482
- if (!subject)
483
- throw new Error("team role needs the user id whose role is changing.");
484
- const role = optionalNonEmpty(values.flags.get("--role"));
485
- if (!role)
486
- throw new Error("team role needs --role <role>.");
487
- return { kind: "team", action: "role", targetUserId: subject, role, ...common };
488
- }
489
- throw new Error("team takes one verb: members, invite, or role.");
490
- }
491
- /** `cockpit model [show|set <key>]` — the alias surface for the personal chat model. */
492
- function parseModelArgs(args) {
493
- const values = parseNamedArgs(args, {
494
- allowedFlags: ["--home", "--dashboard-url", "--json"],
495
- valueFlags: ["--home", "--dashboard-url"],
496
- });
497
- const [verb, key, ...extra] = values.positionals;
498
- if (extra.length > 0)
499
- throw new Error("model does not accept that many arguments.");
500
- const common = {
501
- homeDir: optionalNonEmpty(values.flags.get("--home")),
502
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
503
- json: values.booleans.has("--json"),
504
- };
505
- if (!verb || verb === "show") {
506
- if (key)
507
- throw new Error("model show does not accept positional arguments.");
508
- return { kind: "model", action: "show", ...common };
509
- }
510
- if (verb === "set") {
511
- if (!key)
512
- throw new Error("model set needs a model key, e.g. openai:gpt-5.6-terra.");
513
- return { kind: "model", action: "set", modelKey: key, ...common };
514
- }
515
- throw new Error("model takes one verb: show or set <key>.");
516
- }
517
- function parseStartArgs(args) {
518
- const values = parseNamedArgs(args, {
519
- allowedFlags: [
520
- "--home",
521
- "--repo",
522
- "--workspace",
523
- "--branch",
524
- "--ticket",
525
- "--clear-ticket",
526
- "--topic",
527
- "--topic-summary",
528
- "--intent",
529
- "--phase",
530
- "--intent-source",
531
- "--intent-confidence",
532
- "--operator-id",
533
- "--session-id",
534
- "--json",
535
- "--max-depth",
536
- "--max-repos",
537
- ],
538
- valueFlags: [
539
- "--home",
540
- "--repo",
541
- "--workspace",
542
- "--branch",
543
- "--ticket",
544
- "--topic",
545
- "--topic-summary",
546
- "--intent",
547
- "--phase",
548
- "--intent-source",
549
- "--intent-confidence",
550
- "--operator-id",
551
- "--session-id",
552
- "--max-depth",
553
- "--max-repos",
554
- ],
555
- });
556
- assertNoPositionals(values.positionals, "start");
557
- const activeTicketId = optionalNonEmpty(values.flags.get("--ticket"));
558
- const clearTicket = values.booleans.has("--clear-ticket");
559
- if (activeTicketId && clearTicket) {
560
- throw new Error("--ticket and --clear-ticket cannot be combined.");
561
- }
562
- const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
563
- const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
564
- const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
565
- const workPhase = optionalSchemaValue(WorkPhaseSchema, values.flags.get("--phase"), "--phase");
566
- const intentConfidence = optionalConfidence(values.flags.get("--intent-confidence"), "--intent-confidence");
567
- const explicitIntentMetadata = Boolean(topicLabel ||
568
- topicSummaryRedacted ||
569
- workIntent ||
570
- workPhase ||
571
- intentConfidence !== undefined);
572
- return {
573
- kind: "start",
574
- homeDir: optionalNonEmpty(values.flags.get("--home")),
575
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
576
- branch: optionalNonEmpty(values.flags.get("--branch")),
577
- activeTicketId,
578
- clearTicket,
579
- topicLabel,
580
- topicSummaryRedacted,
581
- workIntent,
582
- workPhase,
583
- intentSource: optionalSchemaValue(IntentSourceSchema, values.flags.get("--intent-source"), "--intent-source") ?? (explicitIntentMetadata ? "explicit_user" : undefined),
584
- intentConfidence,
585
- operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
586
- sessionId: optionalNonEmpty(values.flags.get("--session-id")),
587
- json: values.booleans.has("--json"),
588
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
589
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
590
- };
591
- }
592
- function parseSyncArgs(args) {
593
- return parseSyncLikeArgs(args, "sync");
594
- }
595
- function parseAnalyzeArgs(args) {
596
- return parseSyncLikeArgs(args, "analyze");
597
- }
598
- function parseSyncLikeArgs(args, command) {
599
- const values = parseNamedArgs(args, {
600
- allowedFlags: [
601
- "--home",
602
- "--repo",
603
- "--workspace",
604
- "--dashboard-url",
605
- "--json",
606
- "--max-depth",
607
- "--max-repos",
608
- ],
609
- valueFlags: [
610
- "--home",
611
- "--repo",
612
- "--workspace",
613
- "--dashboard-url",
614
- "--max-depth",
615
- "--max-repos",
616
- ],
617
- });
618
- assertNoPositionals(values.positionals, command);
619
- return {
620
- kind: command,
621
- homeDir: optionalNonEmpty(values.flags.get("--home")),
622
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
623
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
624
- json: values.booleans.has("--json"),
625
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
626
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
627
- };
628
- }
629
- function parseBackfillArgs(args) {
630
- const values = parseNamedArgs(args, {
631
- allowedFlags: [
632
- "--home",
633
- "--repo",
634
- "--workspace",
635
- "--since-days",
636
- "--all",
637
- "--source",
638
- "--dry-run",
639
- "--max-files",
640
- "--max-depth",
641
- "--max-repos",
642
- "--yes",
643
- "--json",
644
- ],
645
- valueFlags: [
646
- "--home",
647
- "--repo",
648
- "--workspace",
649
- "--since-days",
650
- "--source",
651
- "--max-files",
652
- "--max-depth",
653
- "--max-repos",
654
- ],
655
- });
656
- assertNoPositionals(values.positionals, "backfill");
657
- const source = values.flags.get("--source");
658
- if (source !== undefined && source !== "codex" && source !== "claude") {
659
- throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
660
- }
661
- const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
662
- const all = values.booleans.has("--all");
663
- if (all && sinceDays !== undefined) {
664
- throw new Error("--since-days and --all cannot be combined.");
665
- }
666
- return {
667
- kind: "backfill",
668
- homeDir: optionalNonEmpty(values.flags.get("--home")),
669
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
670
- source,
671
- sinceDays,
672
- all,
673
- dryRun: values.booleans.has("--dry-run"),
674
- maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
675
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
676
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
677
- yes: values.booleans.has("--yes"),
678
- json: values.booleans.has("--json"),
679
- };
680
- }
681
- function parseStatusArgs(args) {
682
- const values = parseNamedArgs(args, {
683
- allowedFlags: [
684
- "--home",
685
- "--repo",
686
- "--workspace",
687
- "--json",
688
- "--max-depth",
689
- "--max-repos",
690
- ],
691
- valueFlags: [
692
- "--home",
693
- "--repo",
694
- "--workspace",
695
- "--max-depth",
696
- "--max-repos",
697
- ],
698
- });
699
- assertNoPositionals(values.positionals, "status");
700
- return {
701
- kind: "status",
702
- homeDir: optionalNonEmpty(values.flags.get("--home")),
703
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
704
- json: values.booleans.has("--json"),
705
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
706
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
707
- };
708
- }
709
- function parseSessionsArgs(args) {
710
- const values = parseNamedArgs(args, {
711
- allowedFlags: [
712
- "--home",
713
- "--repo",
714
- "--workspace",
715
- "--source",
716
- "--since-days",
717
- "--all",
718
- "--json",
719
- "--max-depth",
720
- "--max-repos",
721
- ],
722
- valueFlags: [
723
- "--home",
724
- "--repo",
725
- "--workspace",
726
- "--source",
727
- "--since-days",
728
- "--max-depth",
729
- "--max-repos",
730
- ],
731
- });
732
- assertNoPositionals(values.positionals, "sessions");
733
- const source = values.flags.get("--source");
734
- if (source !== undefined && source !== "codex" && source !== "claude") {
735
- throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
736
- }
737
- const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
738
- const all = values.booleans.has("--all");
739
- if (all && sinceDays !== undefined) {
740
- throw new Error("--since-days and --all cannot be combined.");
741
- }
742
- return {
743
- kind: "sessions",
744
- homeDir: optionalNonEmpty(values.flags.get("--home")),
745
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
746
- source,
747
- sinceDays,
748
- all,
749
- json: values.booleans.has("--json"),
750
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
751
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
752
- };
753
- }
754
- function parseServeArgs(args) {
755
- const values = parseNamedArgs(args, {
756
- allowedFlags: ["--home", "--repo", "--workspace", "--port"],
757
- valueFlags: ["--home", "--repo", "--workspace", "--port"],
758
- });
759
- assertNoPositionals(values.positionals, "serve");
760
- const port = Number(values.flags.get("--port") ?? "4174");
761
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
762
- throw new Error("--port must be a TCP port between 1 and 65535.");
763
- }
764
- return {
765
- kind: "serve",
766
- homeDir: optionalNonEmpty(values.flags.get("--home")),
767
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
768
- port,
769
- };
770
- }
771
- function parseAutostartArgs(args) {
772
- const values = parseNamedArgs(args, {
773
- allowedFlags: [
774
- "--home",
775
- "--repo",
776
- "--workspace",
777
- "--dashboard-url",
778
- "--interval-seconds",
779
- "--parent-pid",
780
- "--json",
781
- ],
782
- valueFlags: [
783
- "--home",
784
- "--repo",
785
- "--workspace",
786
- "--dashboard-url",
787
- "--interval-seconds",
788
- "--parent-pid",
789
- ],
790
- });
791
- if (values.positionals.length > 1) {
792
- throw new Error("autostart accepts at most one action (install|uninstall|status).");
793
- }
794
- const action = values.positionals[0] ?? "install";
795
- if (action !== "install" &&
796
- action !== "uninstall" &&
797
- action !== "status" &&
798
- action !== "heal-detached") {
799
- throw new Error("autostart action must be install, uninstall, or status.");
800
- }
801
- return {
802
- kind: "autostart",
803
- action,
804
- homeDir: optionalNonEmpty(values.flags.get("--home")),
805
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
806
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
807
- intervalSeconds: optionalPositiveInteger(values.flags.get("--interval-seconds"), "--interval-seconds") ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS,
808
- parentPid: optionalPositiveInteger(values.flags.get("--parent-pid"), "--parent-pid"),
809
- json: values.booleans.has("--json"),
810
- };
811
- }
812
- function parseAgentRulesArgs(args) {
813
- const values = parseNamedArgs(args, {
814
- allowedFlags: ["--home", "--host", "--repo", "--workspace", "--json"],
815
- valueFlags: ["--home", "--host", "--repo", "--workspace"],
816
- });
817
- if (values.positionals.length > 1) {
818
- throw new Error("agent-rules accepts at most one action (install|uninstall|status).");
819
- }
820
- const action = values.positionals[0] ?? "install";
821
- if (action !== "install" && action !== "uninstall" && action !== "status") {
822
- throw new Error("agent-rules action must be install, uninstall, or status.");
823
- }
824
- return {
825
- kind: "agent-rules",
826
- action,
827
- host: parseAgentRulesHost(values.flags.get("--host")),
828
- homeDir: optionalNonEmpty(values.flags.get("--home")),
829
- repoRoot: optionalNonEmpty(workRootFlagValue(values)),
830
- json: values.booleans.has("--json"),
831
- };
832
- }
833
- function parseAgentRulesHost(value) {
834
- const host = value?.trim().toLowerCase() || "all";
835
- if (host === "codex" || host === "claude" || host === "all")
836
- return host;
837
- throw new Error("agent-rules --host must be codex, claude, or all.");
838
- }
839
- function parseJarvisArgs(args) {
840
- const values = parseNamedArgs(args, {
841
- allowedFlags: [
842
- "--home",
843
- "--dashboard-url",
844
- "--prompt",
845
- "--as",
846
- "--thread",
847
- "--model",
848
- "--image",
849
- "--file",
850
- "--no-stream",
851
- // BLI-3484: which day's page this turn is about.
852
- "--date",
853
- // BLI-3458: reading back what was already said, rather than saying
854
- // something new. Neither takes a turn or reaches the model.
855
- "--threads",
856
- "--history",
857
- "--limit",
858
- "--json",
859
- ],
860
- valueFlags: [
861
- "--home",
862
- "--dashboard-url",
863
- "--prompt",
864
- "--as",
865
- "--thread",
866
- "--model",
867
- "--image",
868
- "--file",
869
- "--date",
870
- "--limit",
871
- ],
872
- });
873
- const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
874
- const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
875
- if (flaggedPrompt && positionalPrompt) {
876
- throw new Error("jarvis accepts either --prompt or positional text, not both.");
877
- }
878
- const thread = optionalNonEmpty(values.flags.get("--thread")) ?? "main";
879
- if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
880
- throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
881
- }
882
- // BLI-3414: `--file` is a plain alias for `--image` — same flag, whichever
883
- // word a person reaches for first.
884
- const image = optionalNonEmpty(values.flags.get("--image"));
885
- const file = optionalNonEmpty(values.flags.get("--file"));
886
- if (image && file) {
887
- throw new Error("jarvis accepts either --image or --file, not both — they are the same flag.");
888
- }
889
- // BLI-3458. Reading history and asking a question are different acts, and a
890
- // command that quietly did one while you asked for the other would be worse
891
- // than a refusal — `--threads` with a question would silently drop the
892
- // question.
893
- const threads = values.booleans.has("--threads");
894
- const history = values.booleans.has("--history");
895
- if (threads && history) {
896
- throw new Error("jarvis --threads lists every thread; --history replays one. Pass one, not both.");
897
- }
898
- if ((threads || history) && (flaggedPrompt || positionalPrompt)) {
899
- throw new Error("jarvis --threads and --history read back what was already said; they do not take a question.");
900
- }
901
- if ((threads || history) && image) {
902
- throw new Error("jarvis --threads and --history do not take an attachment.");
903
- }
904
- // BLI-3484. `--date` binds the page that was live on one of the subject's
905
- // days, so a turn can be about Sunday's page. Refused on the two reading
906
- // commands for the same reason an attachment is: they replay what was said
907
- // and bind no page at all.
908
- const date = optionalNonEmpty(values.flags.get("--date"));
909
- if ((threads || history) && date) {
910
- throw new Error("jarvis --threads and --history replay what was said; they bind no page.");
911
- }
912
- return {
913
- kind: "jarvis",
914
- homeDir: optionalNonEmpty(values.flags.get("--home")),
915
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
916
- prompt: flaggedPrompt ?? positionalPrompt,
917
- subject: optionalNonEmpty(values.flags.get("--as")),
918
- thread,
919
- threads,
920
- history,
921
- limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
922
- // BLI-3381: no client-side allowlist — the dashboard forwards this key
923
- // to the inference server's own allowlist and relays its refusal.
924
- model: optionalNonEmpty(values.flags.get("--model")),
925
- ...(date ? { date } : {}),
926
- imagePath: image ?? file,
927
- // BLI-3457: streaming is on unless a caller opts out. A dashboard that
928
- // does not stream yet still answers plain JSON, so this flag is for
929
- // callers that want the single-body shape on purpose, not a compat knob.
930
- stream: !values.booleans.has("--no-stream"),
931
- json: values.booleans.has("--json"),
932
- };
933
- }
934
- /** The shortest prefix `cockpit scout start` will resolve. Below this, ids collide. */
935
- export const SCOUT_MIN_PREFIX_LENGTH = 6;
936
- /**
937
- * `cockpit scout [--days <n>]` reads the board; `cockpit scout start|dismiss|undo
938
- * <id>` moves one card. Positional shape modelled on `autostart`: an optional
939
- * action word first, then what it acts on.
940
- */
941
- function parseScoutArgs(args) {
942
- const values = parseNamedArgs(args, {
943
- allowedFlags: ["--home", "--dashboard-url", "--days", "--json"],
944
- valueFlags: ["--home", "--dashboard-url", "--days"],
945
- });
946
- if (values.positionals.length > 2) {
947
- throw new Error("scout accepts at most an action (start|dismiss|undo) and one experiment id.");
948
- }
949
- const [rawAction, rawRef] = values.positionals;
950
- const base = {
951
- homeDir: optionalNonEmpty(values.flags.get("--home")),
952
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
953
- days: optionalPositiveInteger(values.flags.get("--days"), "--days"),
954
- json: values.booleans.has("--json"),
955
- };
956
- if (!rawAction)
957
- return { kind: "scout", action: "board", ...base };
958
- if (rawAction !== "start" && rawAction !== "dismiss" && rawAction !== "undo") {
959
- throw new Error("scout action must be start, dismiss, or undo.");
960
- }
961
- const experimentRef = optionalNonEmpty(rawRef);
962
- if (!experimentRef) {
963
- throw new Error(`scout ${rawAction} needs an experiment id from the board.`);
964
- }
965
- if (experimentRef.length < SCOUT_MIN_PREFIX_LENGTH) {
966
- throw new Error(`scout ${rawAction} needs a full experiment id or a prefix of at least ${SCOUT_MIN_PREFIX_LENGTH} characters.`);
967
- }
968
- return { kind: "scout", action: rawAction, experimentRef, ...base };
969
- }
970
- /**
971
- * `cockpit ops status [--job <id>] [--skips]` and
972
- * `cockpit ops recompile --person <p> [--dry-run]` (BLI-3462).
973
- *
974
- * There is deliberately **no `--cadence`** on `recompile`. The compile path
975
- * behind it (`writePageAgain` → `publishPage`) takes no cadence and writes a
976
- * daily; accepting the flag and dropping it is the silent breakage BLI-2490
977
- * forbids, and threading cadence through that path is its own change. Ask for a
978
- * weekly or a monthly with the compile script, which does support it.
979
- */
980
- function parseOpsArgs(args) {
981
- const values = parseNamedArgs(args, {
982
- allowedFlags: [
983
- "--home",
984
- "--dashboard-url",
985
- "--job",
986
- "--skips",
987
- "--person",
988
- "--dry-run",
989
- "--json",
990
- ],
991
- valueFlags: ["--home", "--dashboard-url", "--job", "--person"],
992
- });
993
- if (values.positionals.length > 1) {
994
- throw new Error("ops accepts one action: status or recompile.");
995
- }
996
- const rawAction = values.positionals[0] ?? "status";
997
- if (rawAction !== "status" && rawAction !== "recompile") {
998
- throw new Error("ops action must be status or recompile.");
999
- }
1000
- const base = {
1001
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1002
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1003
- json: values.booleans.has("--json"),
1004
- };
1005
- if (rawAction === "status") {
1006
- return {
1007
- kind: "ops",
1008
- action: "status",
1009
- job: optionalNonEmpty(values.flags.get("--job")),
1010
- skips: values.booleans.has("--skips"),
1011
- ...base,
1012
- };
1013
- }
1014
- const person = optionalNonEmpty(values.flags.get("--person"));
1015
- if (!person) {
1016
- throw new Error("ops recompile needs --person <email|name|id>. Nothing is recompiled by default, on purpose.");
1017
- }
1018
- return {
1019
- kind: "ops",
1020
- action: "recompile",
1021
- person,
1022
- dryRun: values.booleans.has("--dry-run"),
1023
- ...base,
1024
- };
1025
- }
1026
- /** The workspaces Cockpit collects. A typo is refused here rather than searched for. */
1027
- export const SLACK_WORKSPACE_KEYS = ["bli", "blue_pearl"];
1028
- /**
1029
- * `cockpit slack coverage [--workspace <key>] [--stale-only]` and
1030
- * `cockpit slack read [--person|--channel|--query|--since|--until|--limit]`
1031
- * (BLI-3462).
1032
- */
1033
- function parseSlackArgs(args) {
1034
- const values = parseNamedArgs(args, {
1035
- allowedFlags: [
1036
- "--home",
1037
- "--dashboard-url",
1038
- "--workspace",
1039
- "--stale-only",
1040
- "--person",
1041
- "--channel",
1042
- "--query",
1043
- "--since",
1044
- "--until",
1045
- "--limit",
1046
- "--json",
1047
- ],
1048
- valueFlags: [
1049
- "--home",
1050
- "--dashboard-url",
1051
- "--workspace",
1052
- "--person",
1053
- "--channel",
1054
- "--query",
1055
- "--since",
1056
- "--until",
1057
- "--limit",
1058
- ],
1059
- });
1060
- if (values.positionals.length > 1) {
1061
- throw new Error("slack accepts one action: coverage or read.");
1062
- }
1063
- const rawAction = values.positionals[0] ?? "coverage";
1064
- if (rawAction !== "coverage" && rawAction !== "read") {
1065
- throw new Error("slack action must be coverage or read.");
1066
- }
1067
- const base = {
1068
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1069
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1070
- json: values.booleans.has("--json"),
1071
- };
1072
- if (rawAction === "coverage") {
1073
- // `--workspace` here is a Slack workspace key, NOT the collection-root
1074
- // `--workspace <path>` every setup command takes. Named at the point of use
1075
- // so the two cannot be confused silently.
1076
- const workspace = optionalNonEmpty(values.flags.get("--workspace"));
1077
- if (workspace && !SLACK_WORKSPACE_KEYS.includes(workspace)) {
1078
- throw new Error(`slack --workspace must be one of ${SLACK_WORKSPACE_KEYS.join(", ")}; got ${workspace}.`);
1079
- }
1080
- return {
1081
- kind: "slack",
1082
- action: "coverage",
1083
- workspace,
1084
- staleOnly: values.booleans.has("--stale-only"),
1085
- ...base,
1086
- };
1087
- }
1088
- const person = optionalNonEmpty(values.flags.get("--person"));
1089
- const channel = optionalNonEmpty(values.flags.get("--channel"));
1090
- const query = optionalNonEmpty(values.flags.get("--query"));
1091
- if (!person && !channel && !query) {
1092
- throw new Error("slack read needs at least one of --person, --channel or --query. An unfiltered dump of every " +
1093
- "message is not something this command offers.");
1094
- }
1095
- return {
1096
- kind: "slack",
1097
- action: "read",
1098
- person,
1099
- channel,
1100
- query,
1101
- since: optionalNonEmpty(values.flags.get("--since")),
1102
- until: optionalNonEmpty(values.flags.get("--until")),
1103
- limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
1104
- ...base,
1105
- };
1106
- }
1107
- /** The narrowest width worth wrapping to; below it every line is one word. */
1108
- export const WORKBOOK_MIN_WIDTH = 20;
1109
- /**
1110
- * `cockpit workbook` with nothing lists the library, one positional lists a
1111
- * project's shelf, two read a document.
1112
- */
1113
- function parseWorkbookArgs(args) {
1114
- const values = parseNamedArgs(args, {
1115
- allowedFlags: [
1116
- "--home",
1117
- "--dashboard-url",
1118
- "--section",
1119
- "--width",
1120
- "--markdown",
1121
- "--json",
1122
- ],
1123
- valueFlags: ["--home", "--dashboard-url", "--section", "--width"],
1124
- });
1125
- if (values.positionals.length > 2) {
1126
- throw new Error("workbook accepts at most a project and a document.");
1127
- }
1128
- const project = optionalNonEmpty(values.positionals[0]);
1129
- const doc = optionalNonEmpty(values.positionals[1]);
1130
- const section = optionalNonEmpty(values.flags.get("--section"));
1131
- const markdown = values.booleans.has("--markdown");
1132
- if (!doc && (section || markdown)) {
1133
- throw new Error("workbook --section and --markdown need a project and a document, e.g. `cockpit workbook tower workbook`.");
1134
- }
1135
- const width = optionalPositiveInteger(values.flags.get("--width"), "--width");
1136
- if (width !== undefined && width < WORKBOOK_MIN_WIDTH) {
1137
- throw new Error(`workbook --width must be at least ${WORKBOOK_MIN_WIDTH}.`);
1138
- }
1139
- return {
1140
- kind: "workbook",
1141
- project,
1142
- doc,
1143
- section,
1144
- markdown,
1145
- width,
1146
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1147
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1148
- json: values.booleans.has("--json"),
1149
- };
1150
- }
1151
- /**
1152
- * `cockpit brief` (BLI-3458) — the TODAY page, in the terminal.
1153
- *
1154
- * `--for` is the terminal's `?p=`, and it follows the same rule the website
1155
- * does: the dashboard resolves it against the roster and the database (or the
1156
- * route's own mirror of the database's rule) decides whether the page opens.
1157
- * Nothing is decided here.
1158
- *
1159
- * `--date` (BLI-3484) is the terminal's `?d=`, and the same sentence applies to
1160
- * it twice over: what counts as a date, where that person's day starts, and
1161
- * whether anything was compiled for it are all the dashboard's answer. A local
1162
- * copy of "when does Tuesday begin" would be a second answer to a question this
1163
- * product already answers per person, per zone.
1164
- */
1165
- function parseBriefArgs(args) {
1166
- const values = parseNamedArgs(args, {
1167
- allowedFlags: [
1168
- "--home",
1169
- "--dashboard-url",
1170
- "--for",
1171
- "--as",
1172
- "--who",
1173
- "--version",
1174
- "--date",
1175
- "--delta",
1176
- "--against",
1177
- "--days",
1178
- "--tldr",
1179
- "--full",
1180
- "--versions",
1181
- "--claims",
1182
- "--render",
1183
- "--reason",
1184
- "--wait",
1185
- "--no-wait",
1186
- "--json",
1187
- ],
1188
- valueFlags: [
1189
- "--home",
1190
- "--dashboard-url",
1191
- "--for",
1192
- "--as",
1193
- "--who",
1194
- "--version",
1195
- "--date",
1196
- "--against",
1197
- "--days",
1198
- "--reason",
1199
- ],
1200
- });
1201
- // Bare `cockpit brief` reads the page, which is what somebody typing it almost
1202
- // always wants — the same shape `cockpit notes` and `cockpit scout` have.
1203
- // `status` (BLI-3462) answers why a brief was or was not delivered.
1204
- const first = values.positionals[0];
1205
- const action = (first === undefined ? "read" : first);
1206
- if (!["read", "edit", "rewrite", "history", "status"].includes(action)) {
1207
- throw new Error(`Unknown brief command: ${first}. Try edit, rewrite, history or status, or nothing to read it.`);
1208
- }
1209
- if (values.positionals.length > (first === undefined ? 0 : 1)) {
1210
- throw new Error(`brief ${action} does not take "${values.positionals[1]}".`);
1211
- }
1212
- const tldr = values.booleans.has("--tldr");
1213
- const full = values.booleans.has("--full");
1214
- if (tldr && full) {
1215
- throw new Error("brief accepts either --tldr or --full, not both.");
1216
- }
1217
- const wait = values.booleans.has("--wait");
1218
- const noWait = values.booleans.has("--no-wait");
1219
- if (wait && noWait) {
1220
- throw new Error("brief rewrite accepts either --wait or --no-wait, not both.");
1221
- }
1222
- const reason = optionalNonEmpty(values.flags.get("--reason"));
1223
- if (reason && reason.length > 280) {
1224
- // The server's own ceiling (`REASON_MAX`). Said here so the person is told
1225
- // before the page is opened rather than after they have finished editing.
1226
- throw new Error("A reason is limited to 280 characters.");
1227
- }
1228
- if (action !== "edit" && reason) {
1229
- throw new Error("--reason belongs to `cockpit brief edit`.");
1230
- }
1231
- if (action !== "rewrite" && (wait || noWait)) {
1232
- throw new Error("--wait and --no-wait belong to `cockpit brief rewrite`.");
1233
- }
1234
- // ── Reading a past day (BLI-3484) ────────────────────────────────────────
1235
- const date = optionalNonEmpty(values.flags.get("--date"));
1236
- const against = optionalNonEmpty(values.flags.get("--against"));
1237
- const delta = values.booleans.has("--delta");
1238
- const days = optionalPositiveInteger(values.flags.get("--days"), "--days");
1239
- if (date && action !== "read") {
1240
- throw new Error("--date belongs to `cockpit brief` on its own — it reads one day's page.");
1241
- }
1242
- if (delta && action !== "read") {
1243
- throw new Error("--delta belongs to `cockpit brief` on its own.");
1244
- }
1245
- if (against && !delta) {
1246
- // Said rather than silently ignored: somebody who typed `--against` asked
1247
- // for a comparison, and running the plain read would answer a different
1248
- // question without saying so.
1249
- throw new Error("--against needs --delta: it names the day to compare against.");
1250
- }
1251
- if (days !== undefined && action !== "history") {
1252
- throw new Error("--days belongs to `cockpit brief history`.");
1253
- }
1254
- if (action === "history" && (values.booleans.has("--tldr") || values.booleans.has("--claims"))) {
1255
- throw new Error("brief history lists days; --tldr and --claims belong to reading a page.");
1256
- }
1257
- // `--as` is accepted as an alias so the two conversational commands read the
1258
- // same way; `cockpit jarvis --as <person>` has meant this since BLI-3380.
1259
- // `--who` is the third spelling, and it exists because `cockpit brief status
1260
- // --who <person>` is the terminal echo of `jarvis:dm --who`.
1261
- const forPerson = optionalNonEmpty(values.flags.get("--for"));
1262
- const asPerson = optionalNonEmpty(values.flags.get("--as"));
1263
- const whoPerson = optionalNonEmpty(values.flags.get("--who"));
1264
- const named = [forPerson, asPerson, whoPerson].filter(Boolean);
1265
- if (new Set(named).size > 1) {
1266
- throw new Error("brief --for, --as and --who must name the same person.");
1267
- }
1268
- const render = values.booleans.has("--render");
1269
- if (render && action !== "status") {
1270
- throw new Error("brief --render belongs to `cockpit brief status`; the page is printed by default.");
1271
- }
1272
- return {
1273
- kind: "brief",
1274
- action,
1275
- render,
1276
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1277
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1278
- subject: forPerson ?? asPerson ?? whoPerson,
1279
- version: optionalNonEmpty(values.flags.get("--version")),
1280
- ...(date ? { date } : {}),
1281
- ...(delta ? { delta: true } : {}),
1282
- ...(against ? { against } : {}),
1283
- ...(days !== undefined ? { days } : {}),
1284
- tldr,
1285
- versions: values.booleans.has("--versions"),
1286
- claims: values.booleans.has("--claims"),
1287
- ...(reason ? { reason } : {}),
1288
- // Waiting is the default; only an explicit `--no-wait` turns it off. A
1289
- // person who typed `rewrite` wants the page, not a receipt.
1290
- ...(noWait ? { wait: false } : {}),
1291
- json: values.booleans.has("--json"),
1292
- };
1293
- }
1294
- /**
1295
- * `cockpit correct` (BLI-3458) — say that one line on the page is wrong.
1296
- *
1297
- * `--claim` is required and comes from `cockpit brief --claims`, which prints
1298
- * the id beside every line it names. A correction with no claim would be a note
1299
- * about the page in general, and the panel has a sentinel for that; the
1300
- * terminal does not offer it, because a person who has just read
1301
- * `[claimId] the sentence` has the id in front of them.
1302
- *
1303
- * `--text` may be omitted when something is piped in — `git log | cockpit
1304
- * correct --claim x` — and the command reads stdin instead. Absent both, the
1305
- * command refuses rather than filing an empty correction.
1306
- */
1307
- function parseCorrectArgs(args) {
1308
- const values = parseNamedArgs(args, {
1309
- allowedFlags: [
1310
- "--home",
1311
- "--dashboard-url",
1312
- "--claim",
1313
- "--text",
1314
- "--for",
1315
- "--as",
1316
- "--version",
1317
- "--supersedes",
1318
- "--json",
1319
- ],
1320
- valueFlags: [
1321
- "--home",
1322
- "--dashboard-url",
1323
- "--claim",
1324
- "--text",
1325
- "--for",
1326
- "--as",
1327
- "--version",
1328
- "--supersedes",
1329
- ],
1330
- });
1331
- const claimId = optionalNonEmpty(values.flags.get("--claim"));
1332
- if (!claimId) {
1333
- throw new Error("correct needs --claim <claimId>. Run `cockpit brief --claims` to see the id beside every line.");
1334
- }
1335
- const flaggedText = optionalNonEmpty(values.flags.get("--text"));
1336
- const positionalText = optionalNonEmpty(values.positionals.join(" "));
1337
- if (flaggedText && positionalText) {
1338
- throw new Error("correct accepts either --text or positional text, not both.");
1339
- }
1340
- const text = flaggedText ?? positionalText;
1341
- if (text && text.length > 4000) {
1342
- throw new Error("A correction is limited to 4000 characters.");
1343
- }
1344
- const forPerson = optionalNonEmpty(values.flags.get("--for"));
1345
- const asPerson = optionalNonEmpty(values.flags.get("--as"));
1346
- if (forPerson && asPerson && forPerson !== asPerson) {
1347
- throw new Error("correct --for and --as must name the same person.");
1348
- }
1349
- return {
1350
- kind: "correct",
1351
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1352
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1353
- claimId,
1354
- text,
1355
- subject: forPerson ?? asPerson,
1356
- version: optionalNonEmpty(values.flags.get("--version")),
1357
- supersedes: optionalNonEmpty(values.flags.get("--supersedes")),
1358
- json: values.booleans.has("--json"),
1359
- };
1360
- }
1361
- const NOTES_ACTIONS = new Set([
1362
- "list",
1363
- "show",
1364
- "shelf",
1365
- "shelves",
1366
- "upload",
1367
- "paste",
1368
- "share",
1369
- "unshare",
1370
- "move",
1371
- ]);
1372
- /** Actions whose first positional is the note it acts on. */
1373
- const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
1374
- "show",
1375
- "share",
1376
- "unshare",
1377
- "move",
1378
- ]);
1379
- function parseNotesArgs(args) {
1380
- const values = parseNamedArgs(args, {
1381
- allowedFlags: [
1382
- "--home",
1383
- "--dashboard-url",
1384
- "--file",
1385
- "--name",
1386
- "--exclude",
1387
- "--to",
1388
- "--clear-shelf",
1389
- "--series",
1390
- "--kind",
1391
- "--since",
1392
- "--until",
1393
- "--limit",
1394
- "--yes",
1395
- "--json",
1396
- ],
1397
- valueFlags: [
1398
- "--home",
1399
- "--dashboard-url",
1400
- "--file",
1401
- "--name",
1402
- "--exclude",
1403
- "--to",
1404
- "--series",
1405
- "--kind",
1406
- "--since",
1407
- "--until",
1408
- "--limit",
1409
- ],
1410
- });
1411
- // Bare `cockpit notes` is the list, which is the thing a person typing it
1412
- // almost always wants — same reasoning as bare `cockpit` running convergence.
1413
- const first = values.positionals[0];
1414
- const action = (first === undefined ? "list" : first);
1415
- if (!NOTES_ACTIONS.has(action)) {
1416
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, or move.`);
1417
- }
1418
- const rest = values.positionals.slice(first === undefined ? 0 : 1);
1419
- const json = values.booleans.has("--json");
1420
- // `--json` implies `--yes`: a machine-readable run has nobody to prompt, and
1421
- // failing it for want of a confirmation flag it cannot see is a worse
1422
- // surprise than an explicit share it already asked for by name.
1423
- const yes = values.booleans.has("--yes") || json;
1424
- let noteId;
1425
- if (NOTES_ACTIONS_NEEDING_A_NOTE.has(action)) {
1426
- noteId = optionalNonEmpty(rest[0]);
1427
- if (!noteId)
1428
- throw new Error(`notes ${action} needs a note id.`);
1429
- if (rest.length > 1) {
1430
- throw new Error(`notes ${action} takes one note id, not ${rest.length}.`);
1431
- }
1432
- }
1433
- let paths;
1434
- if (action === "upload") {
1435
- // Explicit paths only. No globbing happens here — a shell that expands
1436
- // `*.md` hands us the names it found, and a shell that does not would have
1437
- // this command silently uploading a file literally called `*.md`.
1438
- paths = rest.filter((value) => value.trim() !== "");
1439
- if (paths.length === 0)
1440
- throw new Error("notes upload needs at least one file path.");
1441
- }
1442
- else if (action !== "paste" && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action) && rest.length > 0) {
1443
- throw new Error(`notes ${action} does not take "${rest[0]}".`);
1444
- }
1445
- const to = optionalNonEmpty(values.flags.get("--to"));
1446
- const clearShelf = values.booleans.has("--clear-shelf");
1447
- if (action === "move") {
1448
- if (to && clearShelf) {
1449
- throw new Error("notes move accepts either --to or --clear-shelf, not both.");
1450
- }
1451
- if (!to && !clearShelf) {
1452
- throw new Error('notes move needs --to "<shelf>" or --clear-shelf.');
1453
- }
1454
- }
1455
- const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
1456
- return {
1457
- kind: "notes",
1458
- action,
1459
- homeDir: optionalNonEmpty(values.flags.get("--home")),
1460
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1461
- ...(noteId ? { noteId } : {}),
1462
- ...(paths ? { paths } : {}),
1463
- filePath: optionalNonEmpty(values.flags.get("--file")),
1464
- name: optionalNonEmpty(values.flags.get("--name")),
1465
- exclude: optionalNonEmpty(values.flags.get("--exclude")),
1466
- ...(to ? { to } : {}),
1467
- ...(clearShelf ? { clearShelf } : {}),
1468
- series: optionalNonEmpty(values.flags.get("--series")),
1469
- meetingKind: optionalNonEmpty(values.flags.get("--kind")),
1470
- since: optionalNonEmpty(values.flags.get("--since")),
1471
- until: optionalNonEmpty(values.flags.get("--until")),
1472
- ...(limit === undefined ? {} : { limit }),
1473
- yes,
1474
- json,
1475
- };
1476
- }
1477
- function parseNamedArgs(args, options) {
1478
- const allowed = new Set(options.allowedFlags);
1479
- const valueFlags = new Set(options.valueFlags);
1480
- const flags = new Map();
1481
- const flagValues = new Map();
1482
- const booleans = new Set();
1483
- const positionals = [];
1484
- for (let index = 0; index < args.length; index += 1) {
1485
- const arg = args[index] ?? "";
1486
- rejectServiceRoleLikeArgument(arg);
1487
- if (!arg.startsWith("--")) {
1488
- positionals.push(arg);
1489
- continue;
1490
- }
1491
- const [flag, inlineValue] = arg.split("=", 2);
1492
- if (!allowed.has(flag))
1493
- throw new Error(`Unknown flag: ${flag}`);
1494
- if (valueFlags.has(flag)) {
1495
- const value = inlineValue ?? args[index + 1];
1496
- if (!value || value.startsWith("--")) {
1497
- throw new Error(`${flag} requires a value.`);
1498
- }
1499
- rejectServiceRoleLikeArgument(value);
1500
- flags.set(flag, value);
1501
- const existing = flagValues.get(flag) ?? [];
1502
- existing.push(value);
1503
- flagValues.set(flag, existing);
1504
- if (inlineValue === undefined)
1505
- index += 1;
1506
- }
1507
- else {
1508
- if (inlineValue !== undefined)
1509
- throw new Error(`${flag} does not accept a value.`);
1510
- booleans.add(flag);
1511
- }
1512
- }
1513
- return { flags, flagValues, booleans, positionals };
1514
- }
1515
- function workRootFlagValue(values) {
1516
- const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
1517
- if (provided.length === 0)
1518
- return undefined;
1519
- const uniqueValues = new Set(provided.map((flag) => values.flags.get(flag)).filter(Boolean));
1520
- if (uniqueValues.size > 1) {
1521
- throw new Error("--repo and --workspace must point to the same path.");
1522
- }
1523
- return values.flags.get("--workspace") ?? values.flags.get("--repo");
1524
- }
1525
- function workRootFlagValues(values) {
1526
- const roots = [];
1527
- for (const flag of WORK_ROOT_FLAGS) {
1528
- roots.push(...(values.flagValues.get(flag) ?? []));
1529
- }
1530
- return roots;
1531
- }
1532
- function optionalNonEmptyList(values) {
1533
- const filtered = values
1534
- .map((value) => optionalNonEmpty(value))
1535
- .filter((value) => Boolean(value));
1536
- return filtered.length > 0 ? filtered : undefined;
1537
- }
1538
- function assertNoPositionals(positionals, command) {
1539
- if (positionals.length > 0) {
1540
- throw new Error(`${command} does not accept positional arguments.`);
1541
- }
1542
- }
1543
- function optionalNonEmpty(value) {
1544
- const trimmed = value?.trim();
1545
- return trimmed ? trimmed : undefined;
1546
- }
1547
- function optionalUrl(value) {
1548
- return value === undefined ? undefined : normalizeUrl(value);
1549
- }
1550
- function optionalEmail(value) {
1551
- const trimmed = value?.trim().toLowerCase();
1552
- if (!trimmed)
1553
- return undefined;
1554
- if (!trimmed.includes("@")) {
1555
- throw new Error("--email must be a valid email address.");
1556
- }
1557
- return trimmed;
1558
- }
1559
- function optionalPositiveInteger(value, flag) {
1560
- if (value === undefined)
1561
- return undefined;
1562
- const parsed = Number(value);
1563
- if (!Number.isInteger(parsed) || parsed < 1) {
1564
- throw new Error(`${flag} must be a positive integer.`);
1565
- }
1566
- return parsed;
1567
- }
1568
- function optionalConfidence(value, flag) {
1569
- if (value === undefined)
1570
- return undefined;
1571
- const parsed = Number(value);
1572
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
1573
- throw new Error(`${flag} must be a number between 0 and 1.`);
1574
- }
1575
- return parsed;
1576
- }
1577
- function optionalSchemaValue(schema, value, flag) {
1578
- const trimmed = optionalNonEmpty(value);
1579
- if (!trimmed)
1580
- return undefined;
1581
- const parsed = schema.safeParse(trimmed);
1582
- if (!parsed.success || parsed.data === undefined) {
1583
- throw new Error(`${flag} has an unsupported value.`);
1584
- }
1585
- return parsed.data;
1586
- }
1587
- export function normalizeUrl(value) {
1588
- const trimmed = value.trim().replace(/\/+$/, "");
1589
- if (!trimmed)
1590
- throw new Error("URL value cannot be empty.");
1591
- return trimmed;
1592
- }
1593
- function rejectServiceRoleLikeArgument(value) {
1594
- if (!looksLikeServiceRoleSecret(value))
1595
- return;
1596
- throw new Error("Service-role credentials are not accepted by local collector commands.");
1597
- }
1598
- function looksLikeServiceRoleSecret(value) {
1599
- if (serviceCredentialNamePattern().test(value))
1600
- return true;
1601
- const parts = value.split(".");
1602
- if (parts.length !== 3)
1603
- return false;
1604
- try {
1605
- const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
1606
- return serviceCredentialPayloadPattern().test(payload);
1607
- }
1608
- catch {
1609
- // Deliberately silent (BLI-3238), and it must stay silent: this is the
1610
- // "is this argument a service-role JWT?" test, so a value that will not
1611
- // decode is simply not one. Anything logged here would be a fragment of a
1612
- // credential.
1613
- return false;
1614
- }
1615
- }
1616
- function serviceCredentialNamePattern() {
1617
- return new RegExp([
1618
- ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
1619
- ["service", "role"].join("[_-]?"),
1620
- ].join("|"), "i");
1621
- }
1622
- function serviceCredentialPayloadPattern() {
1623
- const privilegedRole = ["service", "role"].join("_");
1624
- return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
1625
- }
1626
- function base64UrlToBase64(value) {
1627
- const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
1628
- return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
1629
88
  }