@bli-cockpit/cli 0.2.112 → 0.2.113

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.
@@ -2,7 +2,8 @@
2
2
  * The commands that manage this machine's account or watch the pipeline
3
3
  * rather than carry on a conversation: `cockpit scout`, `cockpit ops`,
4
4
  * `cockpit slack`, `cockpit settings`, `cockpit team`, and `cockpit model`.
5
- * Split out of local-args-tower.ts (BLI-3636).
5
+ * Split out of local-args-tower.ts (BLI-3636); the `settings` sections moved
6
+ * once more, into `./local-args-tower-settings.js`, and are re-exported below.
6
7
  *
7
8
  * Every parser moved verbatim: same flags, same defaults, same refusal
8
9
  * sentences. The two constants this family publishes
@@ -11,6 +12,12 @@
11
12
  */
12
13
  import { optionalEmail, optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
13
14
  import { isTeamDeviceRevokeReasonLabel, TEAM_DEVICE_REVOKE_REASON_LABELS, } from "./team-device-reasons.js";
15
+ /**
16
+ * `cockpit settings` has its own file: five sections, one set of rules each.
17
+ * It is re-exported here because this is the address every caller imports it
18
+ * from.
19
+ */
20
+ export { parseSettingsArgs } from "./local-args-tower-settings.js";
14
21
  /** The shortest prefix `cockpit scout start` will resolve. Below this, ids collide. */
15
22
  export const SCOUT_MIN_PREFIX_LENGTH = 6;
16
23
  /**
@@ -220,156 +227,6 @@ export function parseSlackArgs(args) {
220
227
  ...base,
221
228
  };
222
229
  }
223
- const SETTINGS_SECTIONS = ["personal", "switches", "models", "env", "cli-floor"];
224
- /**
225
- * `cockpit settings [section] [verb] [args]` (BLI-3461).
226
- *
227
- * The flag set is the UNION across sections and the combinations are checked
228
- * afterwards, per section — the same shape `parseDoctorArgs` uses. A flag that
229
- * belongs to another section is refused by name rather than silently ignored,
230
- * because a dropped `--project` on an env write is a change a person believes
231
- * they made.
232
- */
233
- export function parseSettingsArgs(args) {
234
- const values = parseNamedArgs(args, {
235
- allowedFlags: [
236
- "--home",
237
- "--dashboard-url",
238
- "--json",
239
- "--chat-model",
240
- "--brief-model",
241
- "--chat",
242
- "--memory",
243
- "--project",
244
- "--file",
245
- "--id",
246
- "--content-stdin",
247
- "--yes",
248
- ],
249
- valueFlags: [
250
- "--home",
251
- "--dashboard-url",
252
- "--chat-model",
253
- "--brief-model",
254
- "--chat",
255
- "--memory",
256
- "--project",
257
- "--file",
258
- "--id",
259
- ],
260
- });
261
- const [rawSection, ...rest] = values.positionals;
262
- const common = {
263
- homeDir: optionalNonEmpty(values.flags.get("--home")),
264
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
265
- yes: values.booleans.has("--yes"),
266
- json: values.booleans.has("--json"),
267
- };
268
- if (!rawSection) {
269
- if (rest.length > 0)
270
- throw new Error("settings does not accept that argument.");
271
- return { kind: "settings", section: "overview", action: "show", ...common };
272
- }
273
- if (!SETTINGS_SECTIONS.includes(rawSection)) {
274
- throw new Error(`settings section must be one of ${SETTINGS_SECTIONS.join(", ")} — or nothing, for all of them.`);
275
- }
276
- const section = rawSection;
277
- if (section === "personal") {
278
- if (rest.length > 0)
279
- throw new Error("settings personal does not accept positional arguments.");
280
- const chatModel = optionalNonEmpty(values.flags.get("--chat-model"));
281
- const briefModel = optionalNonEmpty(values.flags.get("--brief-model"));
282
- return {
283
- kind: "settings",
284
- section,
285
- action: chatModel || briefModel ? "set" : "show",
286
- chatModel,
287
- briefModel,
288
- ...common,
289
- };
290
- }
291
- if (section === "switches") {
292
- if (rest.length === 0) {
293
- return { kind: "settings", section, action: "show", ...common };
294
- }
295
- if (rest[0] !== "set") {
296
- throw new Error("settings switches takes no verb, or `set <key> <value>`.");
297
- }
298
- const [, key, value, ...extra] = rest;
299
- if (!key || !value || extra.length > 0) {
300
- throw new Error("settings switches set needs exactly a key and a value.");
301
- }
302
- return { kind: "settings", section, action: "set", switchKey: key, switchValue: value, ...common };
303
- }
304
- /**
305
- * BLI-3557: `settings cli-floor` shows the fleet forced-update floor,
306
- * `settings cli-floor <version>` raises it. There is no `set` verb because
307
- * there is nothing else to do to a floor, and no flag for the version because
308
- * a bare positional is how a release says it: `cockpit settings cli-floor
309
- * 0.2.48`. The server refuses a LOWER version; the terminal does not
310
- * second-guess that decision locally.
311
- */
312
- if (section === "cli-floor") {
313
- if (rest.length === 0) {
314
- return { kind: "settings", section, action: "show", ...common };
315
- }
316
- const [version, ...extra] = rest;
317
- if (!version || extra.length > 0) {
318
- throw new Error("settings cli-floor takes no argument, or one version: `cli-floor 0.2.48`.");
319
- }
320
- return { kind: "settings", section, action: "set", floorVersion: version, ...common };
321
- }
322
- if (section === "models") {
323
- if (rest.length === 0) {
324
- return { kind: "settings", section, action: "show", ...common };
325
- }
326
- if (rest[0] !== "set" || rest.length > 1) {
327
- throw new Error("settings models takes no verb, or `set --chat <key>` / `set --memory <id>`.");
328
- }
329
- const orgChatModel = optionalNonEmpty(values.flags.get("--chat"));
330
- const orgMemoryModel = optionalNonEmpty(values.flags.get("--memory"));
331
- if (!orgChatModel && !orgMemoryModel) {
332
- throw new Error("settings models set needs --chat <key>, --memory <id>, or both.");
333
- }
334
- return { kind: "settings", section, action: "set", orgChatModel, orgMemoryModel, ...common };
335
- }
336
- // env
337
- const verb = rest[0] ?? "list";
338
- if (rest.length > 1)
339
- throw new Error("settings env takes one verb: list, set, or delete.");
340
- if (verb === "list") {
341
- return { kind: "settings", section, action: "list", ...common };
342
- }
343
- if (verb === "set") {
344
- const envProject = optionalNonEmpty(values.flags.get("--project"));
345
- const envFile = optionalNonEmpty(values.flags.get("--file"));
346
- if (!envProject || !envFile) {
347
- throw new Error("settings env set needs --project <project> and --file <file name>.");
348
- }
349
- if (!values.booleans.has("--content-stdin")) {
350
- // Deliberate: there is no `--content <value>` flag and never will be. A
351
- // secret on a command line lands in shell history and in every process
352
- // listing on the machine.
353
- throw new Error("settings env set reads the file contents from stdin: add --content-stdin and pipe the file in.");
354
- }
355
- return {
356
- kind: "settings",
357
- section,
358
- action: "set",
359
- envProject,
360
- envFile,
361
- contentStdin: true,
362
- ...common,
363
- };
364
- }
365
- if (verb === "delete") {
366
- const envId = optionalNonEmpty(values.flags.get("--id"));
367
- if (!envId)
368
- throw new Error("settings env delete needs --id <uuid>.");
369
- return { kind: "settings", section, action: "delete", envId, ...common };
370
- }
371
- throw new Error("settings env takes one verb: list, set, or delete.");
372
- }
373
230
  /**
374
231
  * `cockpit team [members|invite <email>|role <userId>|device list|device
375
232
  * revoke <id|name>]` (BLI-3461; `device` added BLI-3559).
@@ -0,0 +1,186 @@
1
+ /**
2
+ * What a person may type at `cockpit settings` (BLI-3461), split out of
3
+ * local-args-tower-admin.ts by section so each section's rules fit on one
4
+ * screen. Every flag, default and refusal sentence moved verbatim;
5
+ * `parseSettingsArgs` is still exported from `./local-args-tower-admin.js`,
6
+ * the address it has always had.
7
+ *
8
+ * The flag set is the UNION across sections and the combinations are checked
9
+ * afterwards, per section — the same shape `parseDoctorArgs` uses. A flag that
10
+ * belongs to another section is refused by name rather than silently ignored,
11
+ * because a dropped `--project` on an env write is a change a person believes
12
+ * they made.
13
+ */
14
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
15
+ const SETTINGS_SECTIONS = ["personal", "switches", "models", "env", "cli-floor"];
16
+ /** `cockpit settings [section] [verb] [args]`: the section picker. */
17
+ export function parseSettingsArgs(args) {
18
+ const values = parseNamedArgs(args, {
19
+ allowedFlags: [
20
+ "--home",
21
+ "--dashboard-url",
22
+ "--json",
23
+ "--chat-model",
24
+ "--brief-model",
25
+ "--chat",
26
+ "--memory",
27
+ "--project",
28
+ "--file",
29
+ "--id",
30
+ "--content-stdin",
31
+ "--yes",
32
+ ],
33
+ valueFlags: [
34
+ "--home",
35
+ "--dashboard-url",
36
+ "--chat-model",
37
+ "--brief-model",
38
+ "--chat",
39
+ "--memory",
40
+ "--project",
41
+ "--file",
42
+ "--id",
43
+ ],
44
+ });
45
+ const [rawSection, ...rest] = values.positionals;
46
+ const common = {
47
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
48
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
49
+ yes: values.booleans.has("--yes"),
50
+ json: values.booleans.has("--json"),
51
+ };
52
+ if (!rawSection) {
53
+ if (rest.length > 0)
54
+ throw new Error("settings does not accept that argument.");
55
+ return { kind: "settings", section: "overview", action: "show", ...common };
56
+ }
57
+ if (!SETTINGS_SECTIONS.includes(rawSection)) {
58
+ throw new Error(`settings section must be one of ${SETTINGS_SECTIONS.join(", ")} — or nothing, for all of them.`);
59
+ }
60
+ switch (rawSection) {
61
+ case "personal":
62
+ return parsePersonalSection(rest, values.flags, common);
63
+ case "switches":
64
+ return parseSwitchesSection(rest, common);
65
+ case "cli-floor":
66
+ return parseCliFloorSection(rest, common);
67
+ case "models":
68
+ return parseModelsSection(rest, values.flags, common);
69
+ case "env":
70
+ return parseEnvSection(rest, values.flags, values.booleans, common);
71
+ }
72
+ }
73
+ /** `settings personal [--chat-model <key>] [--brief-model <key>]`. */
74
+ function parsePersonalSection(rest, flags, common) {
75
+ if (rest.length > 0)
76
+ throw new Error("settings personal does not accept positional arguments.");
77
+ const chatModel = optionalNonEmpty(flags.get("--chat-model"));
78
+ const briefModel = optionalNonEmpty(flags.get("--brief-model"));
79
+ return {
80
+ kind: "settings",
81
+ section: "personal",
82
+ action: chatModel || briefModel ? "set" : "show",
83
+ chatModel,
84
+ briefModel,
85
+ ...common,
86
+ };
87
+ }
88
+ /** `settings switches` or `settings switches set <key> <value>`. */
89
+ function parseSwitchesSection(rest, common) {
90
+ if (rest.length === 0) {
91
+ return { kind: "settings", section: "switches", action: "show", ...common };
92
+ }
93
+ if (rest[0] !== "set") {
94
+ throw new Error("settings switches takes no verb, or `set <key> <value>`.");
95
+ }
96
+ const [, key, value, ...extra] = rest;
97
+ if (!key || !value || extra.length > 0) {
98
+ throw new Error("settings switches set needs exactly a key and a value.");
99
+ }
100
+ return {
101
+ kind: "settings",
102
+ section: "switches",
103
+ action: "set",
104
+ switchKey: key,
105
+ switchValue: value,
106
+ ...common,
107
+ };
108
+ }
109
+ /**
110
+ * BLI-3557: `settings cli-floor` shows the fleet forced-update floor,
111
+ * `settings cli-floor <version>` raises it. There is no `set` verb because
112
+ * there is nothing else to do to a floor, and no flag for the version because
113
+ * a bare positional is how a release says it: `cockpit settings cli-floor
114
+ * 0.2.48`. The server refuses a LOWER version; the terminal does not
115
+ * second-guess that decision locally.
116
+ */
117
+ function parseCliFloorSection(rest, common) {
118
+ if (rest.length === 0) {
119
+ return { kind: "settings", section: "cli-floor", action: "show", ...common };
120
+ }
121
+ const [version, ...extra] = rest;
122
+ if (!version || extra.length > 0) {
123
+ throw new Error("settings cli-floor takes no argument, or one version: `cli-floor 0.2.48`.");
124
+ }
125
+ return { kind: "settings", section: "cli-floor", action: "set", floorVersion: version, ...common };
126
+ }
127
+ /** `settings models` or `settings models set --chat <key> / --memory <id>`. */
128
+ function parseModelsSection(rest, flags, common) {
129
+ if (rest.length === 0) {
130
+ return { kind: "settings", section: "models", action: "show", ...common };
131
+ }
132
+ if (rest[0] !== "set" || rest.length > 1) {
133
+ throw new Error("settings models takes no verb, or `set --chat <key>` / `set --memory <id>`.");
134
+ }
135
+ const orgChatModel = optionalNonEmpty(flags.get("--chat"));
136
+ const orgMemoryModel = optionalNonEmpty(flags.get("--memory"));
137
+ if (!orgChatModel && !orgMemoryModel) {
138
+ throw new Error("settings models set needs --chat <key>, --memory <id>, or both.");
139
+ }
140
+ return {
141
+ kind: "settings",
142
+ section: "models",
143
+ action: "set",
144
+ orgChatModel,
145
+ orgMemoryModel,
146
+ ...common,
147
+ };
148
+ }
149
+ /** `settings env [list|set --project <p> --file <f> --content-stdin|delete --id <uuid>]`. */
150
+ function parseEnvSection(rest, flags, booleans, common) {
151
+ const verb = rest[0] ?? "list";
152
+ if (rest.length > 1)
153
+ throw new Error("settings env takes one verb: list, set, or delete.");
154
+ if (verb === "list") {
155
+ return { kind: "settings", section: "env", action: "list", ...common };
156
+ }
157
+ if (verb === "set") {
158
+ const envProject = optionalNonEmpty(flags.get("--project"));
159
+ const envFile = optionalNonEmpty(flags.get("--file"));
160
+ if (!envProject || !envFile) {
161
+ throw new Error("settings env set needs --project <project> and --file <file name>.");
162
+ }
163
+ if (!booleans.has("--content-stdin")) {
164
+ // Deliberate: there is no `--content <value>` flag and never will be. A
165
+ // secret on a command line lands in shell history and in every process
166
+ // listing on the machine.
167
+ throw new Error("settings env set reads the file contents from stdin: add --content-stdin and pipe the file in.");
168
+ }
169
+ return {
170
+ kind: "settings",
171
+ section: "env",
172
+ action: "set",
173
+ envProject,
174
+ envFile,
175
+ contentStdin: true,
176
+ ...common,
177
+ };
178
+ }
179
+ if (verb === "delete") {
180
+ const envId = optionalNonEmpty(flags.get("--id"));
181
+ if (!envId)
182
+ throw new Error("settings env delete needs --id <uuid>.");
183
+ return { kind: "settings", section: "env", action: "delete", envId, ...common };
184
+ }
185
+ throw new Error("settings env takes one verb: list, set, or delete.");
186
+ }
@@ -0,0 +1,58 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
3
+ export function writeOnboardAutostartBlocker(io, result) {
4
+ writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
5
+ if (result?.message)
6
+ writeLine(io.stderr, `Failure: ${result.message}`);
7
+ writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
8
+ }
9
+ /**
10
+ * The blocked-backfill result, identical in both onboarding arms. `extra`
11
+ * carries the arm-specific keys and is spread where those keys appeared before,
12
+ * so the `--json` key order is unchanged.
13
+ */
14
+ export function writeOnboardBackfillBlockedResult(io, options) {
15
+ if (!options.json) {
16
+ writeOnboardBackfillBlocker(io, options.backfill);
17
+ return;
18
+ }
19
+ writeLine(io.stdout, JSON.stringify({
20
+ ...options.base,
21
+ ...options.extra,
22
+ backfill: onboardBackfillPayload(options.backfill),
23
+ blocker: "backfill_incomplete",
24
+ backfill_failure_reason: options.backfill.failureReason,
25
+ next_step: options.backfill.retryCommand,
26
+ }, null, 2));
27
+ }
28
+ function writeOnboardBackfillBlocker(io, outcome) {
29
+ writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
30
+ writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
31
+ writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
32
+ }
33
+ function onboardBackfillPayload(outcome) {
34
+ return (outcome.result ?? {
35
+ status: "blocked",
36
+ failure_reason: outcome.failureReason,
37
+ retry_command: outcome.retryCommand,
38
+ });
39
+ }
40
+ export function nextStepForOnboardBlocker(blocker, options = {}) {
41
+ switch (blocker) {
42
+ case COLLECTION_ROOT_REQUIRED:
43
+ return missingCollectionRootMessage(options);
44
+ case "ticket":
45
+ case "ticket_binding":
46
+ return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
47
+ case "device_pairing":
48
+ return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit doctor` again.";
49
+ case "network_or_ingest":
50
+ return "Check dashboard URL/network, then run `cockpit doctor`.";
51
+ case "install":
52
+ return "Rerun `cockpit doctor` from the repo root; it will reinstall local config.";
53
+ case "work_context":
54
+ return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
55
+ default:
56
+ return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
57
+ }
58
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeLine } from "./cli-io.js";
2
2
  import { attributedSyncRunStatus, displayTicketId, rawEvidenceSyncLine, writeAgentSessionSummary, } from "./collection-report.js";
3
- import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
3
+ import { writeOnboardAutostartBlocker } from "./onboard-report-blockers.js";
4
+ export { nextStepForOnboardBlocker, writeOnboardAutostartBlocker, writeOnboardBackfillBlockedResult, } from "./onboard-report-blockers.js";
4
5
  export function writeOnboardBanner(command, io) {
5
6
  writeLine(io.stdout, "Setting up Tower");
6
7
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
@@ -54,43 +55,6 @@ export function onboardAgentRulesInstallLine(result) {
54
55
  return "not installed.";
55
56
  }
56
57
  }
57
- export function writeOnboardAutostartBlocker(io, result) {
58
- writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
59
- if (result?.message)
60
- writeLine(io.stderr, `Failure: ${result.message}`);
61
- writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
62
- }
63
- /**
64
- * The blocked-backfill result, identical in both onboarding arms. `extra`
65
- * carries the arm-specific keys and is spread where those keys appeared before,
66
- * so the `--json` key order is unchanged.
67
- */
68
- export function writeOnboardBackfillBlockedResult(io, options) {
69
- if (!options.json) {
70
- writeOnboardBackfillBlocker(io, options.backfill);
71
- return;
72
- }
73
- writeLine(io.stdout, JSON.stringify({
74
- ...options.base,
75
- ...options.extra,
76
- backfill: onboardBackfillPayload(options.backfill),
77
- blocker: "backfill_incomplete",
78
- backfill_failure_reason: options.backfill.failureReason,
79
- next_step: options.backfill.retryCommand,
80
- }, null, 2));
81
- }
82
- function writeOnboardBackfillBlocker(io, outcome) {
83
- writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
84
- writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
85
- writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
86
- }
87
- function onboardBackfillPayload(outcome) {
88
- return (outcome.result ?? {
89
- status: "blocked",
90
- failure_reason: outcome.failureReason,
91
- retry_command: outcome.retryCommand,
92
- });
93
- }
94
58
  /**
95
59
  * Every onboard `--json` payload is `onboardResult` plus the roots this pass
96
60
  * resolved plus whatever extra keys that arm of onboarding adds. Assembling it
@@ -129,25 +93,6 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
129
93
  next_dashboard_path: `${command.dashboardUrl}/my-work`,
130
94
  };
131
95
  }
132
- export function nextStepForOnboardBlocker(blocker, options = {}) {
133
- switch (blocker) {
134
- case COLLECTION_ROOT_REQUIRED:
135
- return missingCollectionRootMessage(options);
136
- case "ticket":
137
- case "ticket_binding":
138
- return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
139
- case "device_pairing":
140
- return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit doctor` again.";
141
- case "network_or_ingest":
142
- return "Check dashboard URL/network, then run `cockpit doctor`.";
143
- case "install":
144
- return "Rerun `cockpit doctor` from the repo root; it will reinstall local config.";
145
- case "work_context":
146
- return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
147
- default:
148
- return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
149
- }
150
- }
151
96
  /** Step 3 of a single-repo onboard: the work context this machine just bound. */
152
97
  export function writeOnboardWorkContextStarted(io, context) {
153
98
  writeLine(io.stdout, "3/5 Work context active.");
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The LADDER block: the queue every session joins at ingest, and what the
3
+ * Railway drainer has done with it. Failures are grouped by the reason the
4
+ * rung named itself with, because "12 failed" sends nobody anywhere.
5
+ */
6
+ export function renderLadder(ladder, dim) {
7
+ const lines = ["", `LADDER ${ladder.summary ?? "(no summary)"}`];
8
+ if (ladder.readError) {
9
+ lines.push(` queue not read (${ladder.readError})`);
10
+ return lines;
11
+ }
12
+ const reasons = Object.entries(ladder.failed_by_reason ?? {}).sort((left, right) => right[1] - left[1]);
13
+ for (const [reason, count] of reasons.slice(0, 8)) {
14
+ lines.push(dim(` ${String(count).padStart(5)} ${reason}`));
15
+ }
16
+ if (reasons.length > 8)
17
+ lines.push(dim(` ${reasons.length - 8} more reasons not shown`));
18
+ if ((ladder.stale_leases ?? 0) > 0) {
19
+ lines.push(dim(` ${ladder.stale_leases} stale lease(s) waiting to be taken over`));
20
+ }
21
+ if (ladder.oldest_pending_session_id) {
22
+ lines.push(dim(` oldest pending session ${ladder.oldest_pending_session_id}`));
23
+ }
24
+ return lines;
25
+ }
@@ -16,7 +16,12 @@ import { renderCoverageBuckets, } from "./ops-render-coverage.js";
16
16
  // BLI-3909: today's spend by model, its own renderer for the same reason the
17
17
  // coverage table has one — this file is at the repo's readability floor.
18
18
  import { renderSpend } from "./ops-render-spend.js";
19
+ // BLI-4341: the ladder queue block, its own renderer for the same reason the
20
+ // coverage table and the spend section have one: this file is at the repo's
21
+ // readability floor.
22
+ import { renderLadder } from "./ops-render-ladder.js";
19
23
  export { renderSpend } from "./ops-render-spend.js";
24
+ export { renderLadder } from "./ops-render-ladder.js";
20
25
  export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
21
26
  export { renderCoverageBuckets };
22
27
  /** The word a person reads. Short, fixed width, and never a bare colour. */
@@ -134,6 +139,8 @@ options = {}) {
134
139
  }
135
140
  if (payload.spend)
136
141
  lines.push(...renderSpend(payload.spend, dim));
142
+ if (payload.ladder)
143
+ lines.push(...renderLadder(payload.ladder, dim));
137
144
  const slack = payload.skips?.slack;
138
145
  const external = payload.skips?.external;
139
146
  if (slack || external) {
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.112");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.113");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What bare `cockpit settings` shows: every section at once (BLI-3461).
3
+ *
4
+ * It lives beside `settings.ts` because it is the only caller that has to hold
5
+ * six independent reads in its head at the same time and decide, per section,
6
+ * between a body, "admin only" and a named failure. The per-section verbs next
7
+ * door each answer for exactly one route.
8
+ *
9
+ * "Admin only" is not an error here. A read that 403s is the system working,
10
+ * so a section this person cannot see renders as one line saying so.
11
+ */
12
+ import { writeLine } from "./cli-io.js";
13
+ import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, renderTeamSummary, } from "./settings-render.js";
14
+ import { asRecord, callTower, isForbidden, writeCommandFailure, } from "./tower-command.js";
15
+ /**
16
+ * Everything at once, with the sections this person may not see named as such.
17
+ *
18
+ * The six reads go out together: they are independent, and a person waiting on
19
+ * six sequential round trips would reasonably conclude the command had hung.
20
+ */
21
+ export async function showOverview(command, tower, io) {
22
+ const [personal, switches, models, env, team, cliFloor] = await Promise.all([
23
+ callTower(tower, { path: "/api/settings/personal", label: "settings personal" }),
24
+ callTower(tower, { path: "/api/settings/switches", label: "settings switches" }),
25
+ callTower(tower, { path: "/api/settings/model-routing", label: "settings models" }),
26
+ callTower(tower, { path: "/api/settings/env-blobs", label: "settings env" }),
27
+ callTower(tower, { path: "/api/team/members", label: "settings team" }),
28
+ // BLI-3557: the floor rides the overview because "which version is the
29
+ // fleet being pulled to?" is a question people ask about their own laptop,
30
+ // and it went unanswered for weeks precisely because nothing showed it.
31
+ callTower(tower, { path: "/api/settings/cli-floor", label: "settings cli-floor" }),
32
+ ]);
33
+ // The one read every signed-in person is entitled to. If THAT is refused,
34
+ // nothing else is going to work either, so say so once and stop.
35
+ if (!personal.ok && !isForbidden(personal)) {
36
+ return writeCommandFailure(io, command.json, personal);
37
+ }
38
+ if (command.json) {
39
+ writeLine(io.stdout, JSON.stringify({
40
+ ok: true,
41
+ personal: sectionPayload(personal),
42
+ switches: sectionPayload(switches),
43
+ models: sectionPayload(models),
44
+ env: sectionPayload(env),
45
+ team: sectionPayload(team),
46
+ cliFloor: sectionPayload(cliFloor),
47
+ }));
48
+ return 0;
49
+ }
50
+ writeLine(io.stdout, "TOWER SETTINGS");
51
+ writeSection(io, "PERSONAL", personal, (body) => renderPersonal(body));
52
+ writeSection(io, "TEAM", team, (body) => renderTeamSummary(body));
53
+ writeSection(io, "SWITCHES", switches, (body) => renderSwitches(body));
54
+ writeSection(io, "MODELS", models, (body) => renderModelRouting(body));
55
+ writeSection(io, "ENV FILES", env, (body) => renderEnvBlobs(body));
56
+ writeSection(io, "FLEET CLI FLOOR", cliFloor, (body) => renderCliFloor(body));
57
+ writeLine(io.stderr, `[settings cli] overview ${JSON.stringify({
58
+ personal: outcome(personal),
59
+ team: outcome(team),
60
+ switches: outcome(switches),
61
+ models: outcome(models),
62
+ env: outcome(env),
63
+ cli_floor: outcome(cliFloor),
64
+ })}`);
65
+ return 0;
66
+ }
67
+ /** One section's heading and body — or the one line that says it is not yours. */
68
+ function writeSection(io, heading, result, render) {
69
+ writeLine(io.stdout, "");
70
+ writeLine(io.stdout, heading);
71
+ if (isForbidden(result)) {
72
+ writeLine(io.stdout, " admin only");
73
+ return;
74
+ }
75
+ if (!result.ok) {
76
+ // Not "admin only" and not shown: a real failure, named where it happened.
77
+ writeLine(io.stdout, ` unavailable — ${result.detail}`);
78
+ return;
79
+ }
80
+ for (const line of render(asRecord(result.body)))
81
+ writeLine(io.stdout, line);
82
+ }
83
+ function sectionPayload(result) {
84
+ if (result.ok)
85
+ return result.body;
86
+ if (isForbidden(result))
87
+ return { visible: false, reason: "admin_only" };
88
+ return { visible: false, reason: result.reason, detail: result.detail };
89
+ }
90
+ function outcome(result) {
91
+ if (result.ok)
92
+ return "ok";
93
+ return isForbidden(result) ? "admin_only" : result.reason;
94
+ }