@bli-cockpit/cli 0.2.37 → 0.2.39

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,158 @@
1
+ /**
2
+ * `cockpit scout` — the Scout board, and the three verbs that move a card, in
3
+ * the terminal (BLI-3460).
4
+ *
5
+ * This half talks to Tower and decides exit codes; `scout-render.ts` turns a
6
+ * board into lines and resolves a typed id, with no io of its own.
7
+ *
8
+ * The board is read FIRST even when a card is being moved, for two reasons: the
9
+ * id prefix is resolved against the board that was actually returned rather than
10
+ * against a guess, and a refused action still leaves a readable board on stdout.
11
+ * Being told no about a decision never costs a person the read they already had.
12
+ *
13
+ * `cockpit scout` is NOT JARVIS's `readScout` tool. This prints the board;
14
+ * `cockpit jarvis "..."` asks a question about it and gets an answer in
15
+ * sentences. Reading is governed by the `scout_page_audience` switch, exactly
16
+ * as the page is; moving a card is super_admin whichever surface asks, and a
17
+ * refusal never costs a person the board.
18
+ */
19
+ import { writeLine } from "./cli-io.js";
20
+ import { renderScoutBoard, resolveExperimentRef, refusalSentence, shortId, truncationLines, } from "./scout-render.js";
21
+ import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
+ /** The dashboard route caps nothing here, but a board read should never hang a shell. */
23
+ const READ_DEADLINE_MS = 30_000;
24
+ export async function runScout(command, io) {
25
+ const session = await loadPairedSession("scout", command.homeDir);
26
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
27
+ const log = (line) => writeLine(io.stderr, line);
28
+ // The board is read first even for an action: the id prefix is resolved
29
+ // against it locally, and a refused action still leaves a readable board.
30
+ const read = await towerJsonRequest({
31
+ dashboardUrl,
32
+ path: command.days ? `/api/cockpit/scout?days=${command.days}` : "/api/cockpit/scout",
33
+ method: "GET",
34
+ deviceToken: session.device_token,
35
+ fetch: io.fetch,
36
+ label: "scout",
37
+ timeoutMs: READ_DEADLINE_MS,
38
+ log,
39
+ });
40
+ if (!read.ok) {
41
+ writeFailure(command, io, read);
42
+ return 1;
43
+ }
44
+ const payload = read.body;
45
+ const board = payload.board ?? {};
46
+ const lines = payload.lines ?? {};
47
+ if (command.action === "board") {
48
+ if (command.json) {
49
+ writeLine(io.stdout, JSON.stringify({ ok: true, audience: payload.audience ?? null, windowDays: payload.windowDays ?? board.windowDays ?? null, board, lines }));
50
+ }
51
+ else {
52
+ for (const line of renderScoutBoard(payload))
53
+ writeLine(io.stdout, line);
54
+ }
55
+ writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
56
+ audience: payload.audience ?? null,
57
+ window_days: payload.windowDays ?? board.windowDays ?? null,
58
+ experiments: board.experiments?.length ?? 0,
59
+ settled: board.settled?.length ?? 0,
60
+ signals: board.signals?.length ?? 0,
61
+ truncated: truncationLines(board).length > 0,
62
+ })}`);
63
+ return 0;
64
+ }
65
+ const ref = command.experimentRef ?? "";
66
+ const resolution = resolveExperimentRef(board, ref);
67
+ if (resolution.status !== "ok") {
68
+ const sentence = refusalSentence(resolution, ref);
69
+ writeLine(io.stderr, `[scout cli] action refused ${JSON.stringify({
70
+ reason: resolution.status,
71
+ action: command.action,
72
+ // The typed reference is a person's own input; only its shape is logged.
73
+ ref_length: ref.length,
74
+ candidates: resolution.status === "ambiguous_prefix" ? resolution.candidates.length : 0,
75
+ })}`);
76
+ if (command.json) {
77
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: resolution.status, detail: sentence }));
78
+ }
79
+ else {
80
+ writeLine(io.stderr, sentence);
81
+ for (const line of renderScoutBoard(payload))
82
+ writeLine(io.stdout, line);
83
+ }
84
+ return 1;
85
+ }
86
+ const serverAction = command.action === "undo" ? "undo_dismiss" : command.action;
87
+ const applied = await towerJsonRequest({
88
+ dashboardUrl,
89
+ path: "/api/cockpit/scout",
90
+ method: "POST",
91
+ deviceToken: session.device_token,
92
+ fetch: io.fetch,
93
+ label: "scout-action",
94
+ timeoutMs: READ_DEADLINE_MS,
95
+ body: { experiment_id: resolution.id, action: serverAction },
96
+ log,
97
+ });
98
+ if (!applied.ok) {
99
+ writeLine(io.stderr, `[scout cli] action failed ${JSON.stringify({
100
+ reason: applied.reason,
101
+ action: serverAction,
102
+ http_status: applied.httpStatus ?? null,
103
+ })}`);
104
+ if (command.json) {
105
+ writeLine(io.stdout, JSON.stringify({
106
+ ok: false,
107
+ error: applied.reason,
108
+ detail: failureDetail(applied),
109
+ httpStatus: applied.httpStatus ?? null,
110
+ board,
111
+ lines,
112
+ }));
113
+ return 1;
114
+ }
115
+ // The server's own refusal first, then one line saying which gate it was —
116
+ // and the board underneath, because being refused an action never costs a
117
+ // person the read they already had.
118
+ writeLine(io.stderr, `Tower refused that action: ${failureDetail(applied)}`);
119
+ if (applied.httpStatus === 403) {
120
+ writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
121
+ }
122
+ for (const line of renderScoutBoard(payload))
123
+ writeLine(io.stdout, line);
124
+ return 1;
125
+ }
126
+ const card = resolution.card;
127
+ writeLine(io.stderr, `[scout cli] action applied ${JSON.stringify({ action: serverAction, http_status: applied.httpStatus })}`);
128
+ if (command.json) {
129
+ writeLine(io.stdout, JSON.stringify({ ok: true, action: serverAction, experiment: applied.body?.experiment ?? null }));
130
+ }
131
+ else {
132
+ writeLine(io.stdout, `${pastTense(command.action)} ${shortId(resolution.id)} · ${card.claimSummary ?? card.title ?? "that card"}`);
133
+ }
134
+ return 0;
135
+ }
136
+ // ----------------------------------------------------------------- small parts
137
+ function writeFailure(command, io, failure) {
138
+ const detail = failureDetail(failure);
139
+ writeLine(io.stderr, `[scout cli] read failed ${JSON.stringify({
140
+ reason: failure.reason,
141
+ http_status: failure.httpStatus ?? null,
142
+ })}`);
143
+ if (command.json) {
144
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: failure.reason, detail, httpStatus: failure.httpStatus ?? null }));
145
+ return;
146
+ }
147
+ writeLine(io.stderr, `Scout could not be read: ${detail}`);
148
+ }
149
+ function failureDetail(failure) {
150
+ return towerFailureDetail(failure.reason, failure.detail);
151
+ }
152
+ function pastTense(action) {
153
+ if (action === "start")
154
+ return "Started";
155
+ if (action === "dismiss")
156
+ return "Dismissed";
157
+ return "Restored";
158
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * How the settings surface reads in a terminal (BLI-3461).
3
+ *
4
+ * Pure functions from a response body to lines of text. No network, no
5
+ * filesystem, no `CliIo` — which is what makes the wording testable, and what
6
+ * keeps `settings.ts` about the decisions rather than the column widths.
7
+ *
8
+ * Every one of these is defensive about shape on purpose. These bodies come off
9
+ * the wire from a dashboard that may be a release ahead or behind the installed
10
+ * CLI, and a missing field should cost one line of output, not the command.
11
+ */
12
+ const INDENT = " ";
13
+ function text(value, fallback = "—") {
14
+ if (typeof value === "string" && value.trim())
15
+ return value;
16
+ if (typeof value === "number" || typeof value === "boolean")
17
+ return String(value);
18
+ return fallback;
19
+ }
20
+ function record(value) {
21
+ return value && typeof value === "object" && !Array.isArray(value)
22
+ ? value
23
+ : {};
24
+ }
25
+ function list(value) {
26
+ return Array.isArray(value) ? value.map(record) : [];
27
+ }
28
+ /** `provider:model`, or `unset` — the same key `--chat-model` accepts. */
29
+ function modelKey(value) {
30
+ const pair = record(value);
31
+ if (typeof pair.provider === "string" && typeof pair.model === "string") {
32
+ return `${pair.provider}:${pair.model}`;
33
+ }
34
+ return "unset";
35
+ }
36
+ export function renderPersonal(body) {
37
+ const lines = [
38
+ `${INDENT}signed in as ${text(body.email)} (${text(body.role, "no role")})`,
39
+ `${INDENT}chat model ${modelKey(body.chatModel)}`,
40
+ `${INDENT}brief model ${modelKey(body.briefModel)}`,
41
+ ];
42
+ const memberships = list(body.teamMemberships);
43
+ if (memberships.length === 0) {
44
+ lines.push(`${INDENT}teams none`);
45
+ }
46
+ else {
47
+ for (const [index, membership] of memberships.entries()) {
48
+ const team = record(membership.teams);
49
+ const label = text(team.name, text(membership.team_id));
50
+ lines.push(`${INDENT}${index === 0 ? "teams " : " "} ${label} (${text(membership.role_in_team)})`);
51
+ }
52
+ }
53
+ // "No teams" and "could not ask" are different answers and must stay apart.
54
+ if (typeof body.membershipsReadFailureReason === "string") {
55
+ lines.push(`${INDENT}teams could not be read: ${body.membershipsReadFailureReason}`);
56
+ }
57
+ return lines;
58
+ }
59
+ export function renderTeamSummary(body) {
60
+ const team = record(body.currentTeam);
61
+ const count = record(body.count);
62
+ if (!team.id) {
63
+ return [`${INDENT}no team yet — ask an admin to add you to one`];
64
+ }
65
+ const lines = [
66
+ `${INDENT}${text(team.name)} (${text(team.slug)}) — ${text(count.total, "0")} people`,
67
+ `${INDENT}${text(count.withAccounts, "0")} with accounts, ${text(count.rosterOnly, "0")} on the roster only`,
68
+ ];
69
+ if (typeof body.rosterError === "string") {
70
+ // An accounts-only list that looks complete is the failure this union
71
+ // exists to prevent; saying nothing here would recreate it in the terminal.
72
+ lines.push(`${INDENT}roster could not be read: ${body.rosterError}`);
73
+ }
74
+ return lines;
75
+ }
76
+ export function renderTeamMembers(body) {
77
+ const lines = renderTeamSummary(body);
78
+ const people = list(body.people);
79
+ if (people.length === 0)
80
+ return lines;
81
+ lines.push("");
82
+ for (const person of people) {
83
+ const standing = text(person.standing, "");
84
+ const name = text(person.displayName, text(person.email));
85
+ lines.push(`${INDENT}${name.padEnd(28)} ${text(person.role, "—").padEnd(20)} ${standing}`);
86
+ if (typeof person.userId === "string") {
87
+ lines.push(`${INDENT}${INDENT}id ${person.userId}`);
88
+ }
89
+ }
90
+ if (body.canManage !== true) {
91
+ lines.push("");
92
+ lines.push(`${INDENT}read-only — only an admin can invite or change roles`);
93
+ }
94
+ return lines;
95
+ }
96
+ export function renderSwitches(body) {
97
+ const switches = list(body.switches);
98
+ if (switches.length === 0)
99
+ return [`${INDENT}no switches registered`];
100
+ const lines = switches.map((entry) => {
101
+ const options = Array.isArray(entry.options)
102
+ ? entry.options.map((option) => text(record(option).value)).join(" | ")
103
+ : "";
104
+ return `${INDENT}${text(entry.key).padEnd(24)} ${text(entry.value).padEnd(12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
105
+ });
106
+ if (typeof body.readFailureReason === "string") {
107
+ lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
108
+ }
109
+ return lines;
110
+ }
111
+ export function renderModelRouting(body) {
112
+ const lines = [
113
+ `${INDENT}chat ${text(body.chatEffectiveLabel)} (${text(body.chatSource)})`,
114
+ `${INDENT}memory ${text(body.memoryEffectiveLabel)} (${text(body.memorySource)}${typeof body.memoryVia === "string" ? ` via ${body.memoryVia}` : ""})`,
115
+ ];
116
+ for (const slot of list(body.displaySlots)) {
117
+ lines.push(`${INDENT}${text(slot.name).padEnd(24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
118
+ }
119
+ if (typeof body.readFailureReason === "string") {
120
+ lines.push(`${INDENT}settings could not be read: ${body.readFailureReason}`);
121
+ }
122
+ return lines;
123
+ }
124
+ /**
125
+ * Names, sizes and stamps only.
126
+ *
127
+ * There is no branch here that could print a value, because the server's own
128
+ * return type has no field carrying one — that is the design, and this renderer
129
+ * is deliberately the boring end of it.
130
+ */
131
+ export function renderEnvBlobs(body) {
132
+ const blobs = list(body.blobs);
133
+ if (blobs.length === 0)
134
+ return [`${INDENT}no env files stored`];
135
+ return blobs.map((blob) => `${INDENT}${text(blob.project).padEnd(12)} ${text(blob.file_name).padEnd(16)} ` +
136
+ `updated ${text(blob.updated_at)} id ${text(blob.id)}`);
137
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * `cockpit settings` — the settings surface in the terminal (BLI-3461).
3
+ *
4
+ * Five sections, the same five tabs a person sees at `/settings`, reading and
5
+ * writing through the same `/api/settings/*` routes the browser uses. Nothing
6
+ * here decides policy: every allowlist, every super_admin check and every audit
7
+ * line lives on the server, so the terminal cannot drift into permitting
8
+ * something the browser refuses.
9
+ *
10
+ * Two things this file is careful about.
11
+ *
12
+ * **"Admin only" is not an error.** Bare `cockpit settings` shows everything
13
+ * this person can see, and renders a section they cannot as one line saying so.
14
+ * A read that 403s is the system working; printing a stack of refusals would
15
+ * teach an intern that the command is broken.
16
+ *
17
+ * **A secret never touches the terminal.** `settings env` lists names, sizes
18
+ * and stamps — the server cannot return a value, and this command has no flag
19
+ * that takes one. New content arrives on stdin and is forwarded without ever
20
+ * being written to stdout, stderr, or an argument vector.
21
+ */
22
+ import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
23
+ import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
24
+ import { renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, renderTeamSummary, } from "./settings-render.js";
25
+ export async function runSettings(command, io) {
26
+ const tower = await openTower("settings", command, io);
27
+ switch (command.section) {
28
+ case "overview":
29
+ return showOverview(command, tower, io);
30
+ case "personal":
31
+ return command.action === "set"
32
+ ? setPersonal(command, tower, io)
33
+ : showPersonal(command, tower, io);
34
+ case "switches":
35
+ return command.action === "set"
36
+ ? setSwitch(command, tower, io)
37
+ : showSwitches(command, tower, io);
38
+ case "models":
39
+ return command.action === "set"
40
+ ? setModels(command, tower, io)
41
+ : showModels(command, tower, io);
42
+ case "env":
43
+ if (command.action === "set")
44
+ return setEnvBlob(command, tower, io);
45
+ if (command.action === "delete")
46
+ return deleteEnvBlob(command, tower, io);
47
+ return listEnvBlobs(command, tower, io);
48
+ }
49
+ }
50
+ // ── overview ──────────────────────────────────────────────────────────
51
+ /**
52
+ * Everything at once, with the sections this person may not see named as such.
53
+ *
54
+ * The five reads go out together: they are independent, and a person waiting on
55
+ * five sequential round trips would reasonably conclude the command had hung.
56
+ */
57
+ async function showOverview(command, tower, io) {
58
+ const [personal, switches, models, env, team] = await Promise.all([
59
+ callTower(tower, { path: "/api/settings/personal", label: "settings personal" }),
60
+ callTower(tower, { path: "/api/settings/switches", label: "settings switches" }),
61
+ callTower(tower, { path: "/api/settings/model-routing", label: "settings models" }),
62
+ callTower(tower, { path: "/api/settings/env-blobs", label: "settings env" }),
63
+ callTower(tower, { path: "/api/team/members", label: "settings team" }),
64
+ ]);
65
+ // The one read every signed-in person is entitled to. If THAT is refused,
66
+ // nothing else is going to work either, so say so once and stop.
67
+ if (!personal.ok && !isForbidden(personal)) {
68
+ return writeCommandFailure(io, command.json, personal);
69
+ }
70
+ if (command.json) {
71
+ writeLine(io.stdout, JSON.stringify({
72
+ ok: true,
73
+ personal: sectionPayload(personal),
74
+ switches: sectionPayload(switches),
75
+ models: sectionPayload(models),
76
+ env: sectionPayload(env),
77
+ team: sectionPayload(team),
78
+ }));
79
+ return 0;
80
+ }
81
+ writeLine(io.stdout, "TOWER SETTINGS");
82
+ writeSection(io, "PERSONAL", personal, (body) => renderPersonal(body));
83
+ writeSection(io, "TEAM", team, (body) => renderTeamSummary(body));
84
+ writeSection(io, "SWITCHES", switches, (body) => renderSwitches(body));
85
+ writeSection(io, "MODELS", models, (body) => renderModelRouting(body));
86
+ writeSection(io, "ENV FILES", env, (body) => renderEnvBlobs(body));
87
+ writeLine(io.stderr, `[settings cli] overview ${JSON.stringify({
88
+ personal: outcome(personal),
89
+ team: outcome(team),
90
+ switches: outcome(switches),
91
+ models: outcome(models),
92
+ env: outcome(env),
93
+ })}`);
94
+ return 0;
95
+ }
96
+ /** One section's heading and body — or the one line that says it is not yours. */
97
+ function writeSection(io, heading, result, render) {
98
+ writeLine(io.stdout, "");
99
+ writeLine(io.stdout, heading);
100
+ if (isForbidden(result)) {
101
+ writeLine(io.stdout, " admin only");
102
+ return;
103
+ }
104
+ if (!result.ok) {
105
+ // Not "admin only" and not shown: a real failure, named where it happened.
106
+ writeLine(io.stdout, ` unavailable — ${result.detail}`);
107
+ return;
108
+ }
109
+ for (const line of render(asRecord(result.body)))
110
+ writeLine(io.stdout, line);
111
+ }
112
+ function sectionPayload(result) {
113
+ if (result.ok)
114
+ return result.body;
115
+ if (isForbidden(result))
116
+ return { visible: false, reason: "admin_only" };
117
+ return { visible: false, reason: result.reason, detail: result.detail };
118
+ }
119
+ function outcome(result) {
120
+ if (result.ok)
121
+ return "ok";
122
+ return isForbidden(result) ? "admin_only" : result.reason;
123
+ }
124
+ // ── personal ──────────────────────────────────────────────────────────
125
+ async function showPersonal(command, tower, io) {
126
+ const result = await callTower(tower, {
127
+ path: "/api/settings/personal",
128
+ label: "settings personal",
129
+ });
130
+ if (!result.ok)
131
+ return writeCommandFailure(io, command.json, result);
132
+ return writeBody(io, command.json, result.body, () => renderPersonal(asRecord(result.body)));
133
+ }
134
+ async function setPersonal(command, tower, io) {
135
+ const result = await callTower(tower, {
136
+ path: "/api/settings/jarvis-model",
137
+ method: "POST",
138
+ label: "settings personal set",
139
+ body: {
140
+ ...(command.chatModel ? { chatModel: parseModelKey(command.chatModel) } : {}),
141
+ ...(command.briefModel ? { briefModel: parseModelKey(command.briefModel) } : {}),
142
+ },
143
+ });
144
+ if (!result.ok)
145
+ return writeCommandFailure(io, command.json, result);
146
+ const body = asRecord(result.body);
147
+ if (command.json) {
148
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
149
+ return 0;
150
+ }
151
+ writeLine(io.stdout, "Saved your model choices.");
152
+ for (const line of renderPersonal(body))
153
+ writeLine(io.stdout, line);
154
+ return 0;
155
+ }
156
+ // ── switches ──────────────────────────────────────────────────────────
157
+ async function showSwitches(command, tower, io) {
158
+ const result = await callTower(tower, {
159
+ path: "/api/settings/switches",
160
+ label: "settings switches",
161
+ });
162
+ if (!result.ok)
163
+ return writeCommandFailure(io, command.json, result);
164
+ return writeBody(io, command.json, result.body, () => renderSwitches(asRecord(result.body)));
165
+ }
166
+ async function setSwitch(command, tower, io) {
167
+ const result = await callTower(tower, {
168
+ path: "/api/settings/switches",
169
+ method: "POST",
170
+ label: "settings switches set",
171
+ body: { key: command.switchKey, value: command.switchValue },
172
+ });
173
+ if (!result.ok)
174
+ return writeCommandFailure(io, command.json, result);
175
+ const body = asRecord(result.body);
176
+ if (command.json) {
177
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
178
+ return 0;
179
+ }
180
+ writeLine(io.stdout, `Switch ${String(body.key)} is now ${String(body.value)}.`);
181
+ return 0;
182
+ }
183
+ // ── models (org-wide) ─────────────────────────────────────────────────
184
+ async function showModels(command, tower, io) {
185
+ const result = await callTower(tower, {
186
+ path: "/api/settings/model-routing",
187
+ label: "settings models",
188
+ });
189
+ if (!result.ok) {
190
+ if (isForbidden(result) && !command.json) {
191
+ writeLine(io.stderr, "The org-wide model map is admin only.");
192
+ return 1;
193
+ }
194
+ return writeCommandFailure(io, command.json, result);
195
+ }
196
+ return writeBody(io, command.json, result.body, () => renderModelRouting(asRecord(result.body)));
197
+ }
198
+ async function setModels(command, tower, io) {
199
+ const result = await callTower(tower, {
200
+ path: "/api/settings/model-routing",
201
+ method: "POST",
202
+ label: "settings models set",
203
+ body: {
204
+ ...(command.orgChatModel ? { chatModel: parseModelKey(command.orgChatModel) } : {}),
205
+ ...(command.orgMemoryModel ? { memoryModel: command.orgMemoryModel } : {}),
206
+ },
207
+ });
208
+ if (!result.ok)
209
+ return writeCommandFailure(io, command.json, result);
210
+ const body = asRecord(result.body);
211
+ if (command.json) {
212
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
213
+ return 0;
214
+ }
215
+ writeLine(io.stdout, `Saved: ${asList(body.saved).join(", ") || "nothing"}.`);
216
+ return 0;
217
+ }
218
+ // ── env files ─────────────────────────────────────────────────────────
219
+ async function listEnvBlobs(command, tower, io) {
220
+ const result = await callTower(tower, {
221
+ path: "/api/settings/env-blobs",
222
+ label: "settings env list",
223
+ });
224
+ if (!result.ok)
225
+ return writeCommandFailure(io, command.json, result);
226
+ return writeBody(io, command.json, result.body, () => renderEnvBlobs(asRecord(result.body)));
227
+ }
228
+ /**
229
+ * Content arrives on stdin and leaves in the request body. It is never printed,
230
+ * never logged, and never an argument — the log line below carries the byte
231
+ * count and nothing else.
232
+ *
233
+ * Works the same on both supported hosts: `cat <path> | cockpit settings env set
234
+ * …` on macOS, `Get-Content <path> -Raw | cockpit settings env set …` in
235
+ * PowerShell. No shell built-ins, no here-strings, no process substitution.
236
+ */
237
+ async function setEnvBlob(command, tower, io) {
238
+ if (isInteractiveStdin(io)) {
239
+ const failure = {
240
+ reason: "content_not_piped",
241
+ detail: "settings env set reads the file from stdin. Pipe it in: " +
242
+ "`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin` " +
243
+ "(PowerShell: `Get-Content <path> -Raw | cockpit settings env set …`).",
244
+ };
245
+ return writeCommandFailure(io, command.json, failure);
246
+ }
247
+ const content = await readAllStdin(io.stdin);
248
+ if (!content.trim()) {
249
+ return writeCommandFailure(io, command.json, {
250
+ reason: "content_empty",
251
+ detail: "Nothing arrived on stdin, so there is nothing to store.",
252
+ });
253
+ }
254
+ const result = await callTower(tower, {
255
+ path: "/api/settings/env-blobs",
256
+ method: "POST",
257
+ label: "settings env set",
258
+ body: { project: command.envProject, file_name: command.envFile, content },
259
+ });
260
+ writeLine(io.stderr, `[settings cli] env upload ${JSON.stringify({
261
+ project: command.envProject,
262
+ file: command.envFile,
263
+ byte_size: Buffer.byteLength(content, "utf8"),
264
+ outcome: result.ok ? "stored" : result.reason,
265
+ })}`);
266
+ if (!result.ok)
267
+ return writeCommandFailure(io, command.json, result);
268
+ const body = asRecord(result.body);
269
+ if (command.json) {
270
+ writeLine(io.stdout, JSON.stringify({ ok: true, blob: body.blob ?? null }));
271
+ return 0;
272
+ }
273
+ writeLine(io.stdout, `Stored ${String(command.envFile)} for ${String(command.envProject)}.`);
274
+ return 0;
275
+ }
276
+ async function deleteEnvBlob(command, tower, io) {
277
+ const confirmed = await confirmDestructive(command, io, `Delete env file ${command.envId}? [Y/n] `, "settings env delete");
278
+ if (!confirmed.ok)
279
+ return writeCommandFailure(io, command.json, confirmed);
280
+ const result = await callTower(tower, {
281
+ path: "/api/settings/env-blobs",
282
+ method: "DELETE",
283
+ label: "settings env delete",
284
+ body: { id: command.envId },
285
+ });
286
+ if (!result.ok)
287
+ return writeCommandFailure(io, command.json, result);
288
+ const body = asRecord(result.body);
289
+ if (command.json) {
290
+ writeLine(io.stdout, JSON.stringify({ ok: true, deleted_id: body.deleted_id ?? null }));
291
+ return 0;
292
+ }
293
+ writeLine(io.stdout, `Deleted env file ${String(body.deleted_id ?? command.envId)}.`);
294
+ return 0;
295
+ }
296
+ /**
297
+ * The short way to say `settings personal --chat-model` (BLI-3457 S6).
298
+ *
299
+ * It exists because "which model am I talking to?" is the question people
300
+ * actually ask, and making them remember it lives under `settings personal`
301
+ * is the kind of friction that stops a surface being used at all.
302
+ */
303
+ export async function runModel(command, io) {
304
+ const tower = await openTower("model", command, io);
305
+ if (command.action === "show") {
306
+ const result = await callTower(tower, {
307
+ path: "/api/settings/jarvis-model",
308
+ label: "model show",
309
+ });
310
+ if (!result.ok)
311
+ return writeCommandFailure(io, command.json, result);
312
+ const body = asRecord(result.body);
313
+ if (command.json) {
314
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
315
+ return 0;
316
+ }
317
+ writeLine(io.stdout, `chat model: ${modelKeyOf(body.chatModel)}`);
318
+ writeLine(io.stdout, `brief model: ${modelKeyOf(body.briefModel)}`);
319
+ return 0;
320
+ }
321
+ const result = await callTower(tower, {
322
+ path: "/api/settings/jarvis-model",
323
+ method: "POST",
324
+ label: "model set",
325
+ body: { chatModel: parseModelKey(command.modelKey ?? "") },
326
+ });
327
+ if (!result.ok)
328
+ return writeCommandFailure(io, command.json, result);
329
+ const body = asRecord(result.body);
330
+ if (command.json) {
331
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
332
+ return 0;
333
+ }
334
+ writeLine(io.stdout, `Chat model is now ${modelKeyOf(body.chatModel)}.`);
335
+ return 0;
336
+ }
337
+ export function modelKeyOf(value) {
338
+ const pair = asRecord(value);
339
+ if (typeof pair.provider === "string" && typeof pair.model === "string") {
340
+ return `${pair.provider}:${pair.model}`;
341
+ }
342
+ return "unset";
343
+ }
344
+ // ── shared ────────────────────────────────────────────────────────────
345
+ function writeBody(io, json, body, render) {
346
+ if (json) {
347
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...asRecord(body) }));
348
+ return 0;
349
+ }
350
+ for (const line of render())
351
+ writeLine(io.stdout, line);
352
+ return 0;
353
+ }
354
+ /**
355
+ * A destructive step answers to `--yes` or to a person at a keyboard, and to
356
+ * nothing else. A headless run without `--yes` is refused by name rather than
357
+ * blocking forever on a stdin nobody is typing into.
358
+ */
359
+ export async function confirmDestructive(command, io, prompt, label) {
360
+ if (command.yes)
361
+ return { ok: true };
362
+ if (command.json || !isInteractiveStdin(io)) {
363
+ return {
364
+ ok: false,
365
+ reason: "confirmation_required",
366
+ detail: `${label} changes something that cannot be undone. Re-run it with --yes.`,
367
+ };
368
+ }
369
+ const answer = await readLine(io, prompt);
370
+ if (yesByDefault(answer))
371
+ return { ok: true };
372
+ return {
373
+ ok: false,
374
+ reason: "declined",
375
+ detail: `${label} was cancelled.`,
376
+ };
377
+ }