@bli-cockpit/cli 0.2.39 → 0.2.41
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.
- package/dist/adapters/local-sources.js +51 -5
- package/dist/autostart.js +105 -5
- package/dist/commands/brief.js +11 -10
- package/dist/commands/cli-io.js +62 -1
- package/dist/commands/correct.js +5 -17
- package/dist/commands/jarvis.js +8 -20
- package/dist/commands/local-discovery.js +20 -0
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +34 -1
- package/dist/commands/notes-file.js +27 -0
- package/dist/commands/notes.js +24 -19
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +20 -18
- package/dist/commands/scout.js +4 -4
- package/dist/commands/sessions.js +45 -1
- package/dist/commands/settings-render.js +9 -4
- package/dist/commands/settings.js +9 -2
- package/dist/commands/text-width.js +108 -0
- package/dist/commands/tower-command.js +7 -8
- package/dist/commands/workbook-render.js +34 -17
- package/dist/commands/workbook.js +6 -4
- package/dist/evidence-upload-client.js +42 -3
- package/dist/local-state.js +27 -2
- package/dist/upload-agent-artifacts.js +27 -2
- package/dist/upload-failure-reason.js +121 -0
- package/dist/upload-http.js +28 -0
- package/dist/upload.js +122 -21
- package/package.json +1 -1
package/dist/commands/notes.js
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* run has nobody to ask and asking it to guess is worse than the flag it
|
|
24
24
|
* already typed the command for.
|
|
25
25
|
*/
|
|
26
|
-
import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
|
|
27
|
-
import { NOTE_SLOW_UPLOAD_BYTES, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
|
|
26
|
+
import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
|
|
27
|
+
import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
|
|
28
28
|
import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
|
|
29
29
|
import { readResponseJson } from "../upload-http.js";
|
|
30
30
|
/**
|
|
@@ -261,15 +261,24 @@ async function pasteNote(command, door) {
|
|
|
261
261
|
writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
|
|
262
262
|
return fail(door, read.refusal, sentence);
|
|
263
263
|
}
|
|
264
|
-
|
|
264
|
+
const decoded = decodeTextBytes(read.bytes);
|
|
265
|
+
if (!decoded.ok) {
|
|
266
|
+
writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
|
|
267
|
+
return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
|
|
268
|
+
}
|
|
269
|
+
text = decoded.text;
|
|
265
270
|
}
|
|
266
271
|
else {
|
|
267
272
|
if (isInteractiveStdin(door.io)) {
|
|
268
|
-
return fail(door, "nothing_piped", "
|
|
269
|
-
+ "`
|
|
273
|
+
return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
|
|
274
|
+
+ "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 — "
|
|
275
|
+
+ "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
|
|
270
276
|
}
|
|
271
277
|
try {
|
|
272
|
-
text = await
|
|
278
|
+
text = await readPipedText(door.io.stdin, {
|
|
279
|
+
maxChars: PASTE_MAX_CHARS,
|
|
280
|
+
overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
|
|
281
|
+
});
|
|
273
282
|
}
|
|
274
283
|
catch (error) {
|
|
275
284
|
return fail(door, "paste_too_long", errorText(error));
|
|
@@ -284,8 +293,14 @@ async function pasteNote(command, door) {
|
|
|
284
293
|
form.set("name", command.name);
|
|
285
294
|
if (command.exclude)
|
|
286
295
|
form.set("exclusions", command.exclude);
|
|
287
|
-
|
|
288
|
-
|
|
296
|
+
// Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
|
|
297
|
+
// threshold, and the file path above already compares `bytes.byteLength`
|
|
298
|
+
// against it; `text.length` counts UTF-16 units, so a note in any non-Latin
|
|
299
|
+
// script was measured at a third to a half of the size actually being sent
|
|
300
|
+
// and the "this will take a while" line stayed silent through the wait.
|
|
301
|
+
const pastedBytes = Buffer.byteLength(text, "utf8");
|
|
302
|
+
if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
|
|
303
|
+
writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
|
|
289
304
|
}
|
|
290
305
|
const answer = await ask(door, {
|
|
291
306
|
path: "/api/notes/upload",
|
|
@@ -302,6 +317,7 @@ async function pasteNote(command, door) {
|
|
|
302
317
|
note_id: body.noteId ?? null,
|
|
303
318
|
scope: body.scope ?? null,
|
|
304
319
|
chars: text.length,
|
|
320
|
+
byte_size: pastedBytes,
|
|
305
321
|
named_by_caller: Boolean(command.name),
|
|
306
322
|
})}`);
|
|
307
323
|
if (door.json)
|
|
@@ -474,17 +490,6 @@ function sayScope(door, body) {
|
|
|
474
490
|
writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
|
|
475
491
|
writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
|
|
476
492
|
}
|
|
477
|
-
async function readAll(stream) {
|
|
478
|
-
stream.setEncoding("utf8");
|
|
479
|
-
let text = "";
|
|
480
|
-
for await (const chunk of stream) {
|
|
481
|
-
text += chunk;
|
|
482
|
-
if (text.length > PASTE_MAX_CHARS) {
|
|
483
|
-
throw new Error(`A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
return text;
|
|
487
|
-
}
|
|
488
493
|
function errorText(error) {
|
|
489
494
|
return error instanceof Error ? error.message : String(error);
|
|
490
495
|
}
|
|
@@ -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.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.41");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* **A bounded read says so.** The server returns at most 8 cards and 12
|
|
16
16
|
* signals; when there are more, `truncationLines` prints how many. A quiet
|
|
17
17
|
* board and a truncated board must never look alike.
|
|
18
|
+
*
|
|
19
|
+
* **Styling arrives as an argument, never as a global.** `styled` says whether
|
|
20
|
+
* this run's stdout is a terminal that asked for colour (BLI-3482); `scout.ts`
|
|
21
|
+
* decides it once with `colorEnabled(io)` and the layout stays deterministic.
|
|
18
22
|
*/
|
|
19
|
-
|
|
20
|
-
const RESET = "\x1b[0m";
|
|
23
|
+
import { dim } from "./cli-io.js";
|
|
21
24
|
// ------------------------------------------------------------------ rendering
|
|
22
25
|
/** The whole board as terminal lines, in the page's three sections and order. */
|
|
23
|
-
export function renderScoutBoard(payload) {
|
|
26
|
+
export function renderScoutBoard(payload, styled) {
|
|
24
27
|
const board = payload.board ?? {};
|
|
25
28
|
const lines = payload.lines ?? {};
|
|
26
29
|
const out = [];
|
|
@@ -35,7 +38,7 @@ export function renderScoutBoard(payload) {
|
|
|
35
38
|
}
|
|
36
39
|
else {
|
|
37
40
|
for (const card of experiments)
|
|
38
|
-
out.push(...renderExperiment(card));
|
|
41
|
+
out.push(...renderExperiment(card, styled));
|
|
39
42
|
}
|
|
40
43
|
const settled = board.settled ?? [];
|
|
41
44
|
if (settled.length > 0) {
|
|
@@ -51,14 +54,14 @@ export function renderScoutBoard(payload) {
|
|
|
51
54
|
}
|
|
52
55
|
else {
|
|
53
56
|
for (const signal of signals)
|
|
54
|
-
out.push(...renderSignal(signal));
|
|
57
|
+
out.push(...renderSignal(signal, styled));
|
|
55
58
|
}
|
|
56
59
|
const truncation = truncationLines(board);
|
|
57
60
|
if (truncation.length > 0)
|
|
58
61
|
out.push("", ...truncation);
|
|
59
62
|
return out;
|
|
60
63
|
}
|
|
61
|
-
function renderExperiment(card) {
|
|
64
|
+
function renderExperiment(card, styled) {
|
|
62
65
|
const sourceCount = card.sourceCount ?? 0;
|
|
63
66
|
const head = [
|
|
64
67
|
shortId(card.id ?? ""),
|
|
@@ -74,23 +77,25 @@ function renderExperiment(card) {
|
|
|
74
77
|
if (card.claimSummary)
|
|
75
78
|
out.push(` ${card.claimSummary}`);
|
|
76
79
|
if (card.title)
|
|
77
|
-
out.push(dim(` ${card.title}
|
|
80
|
+
out.push(dim(` ${card.title}`, styled));
|
|
78
81
|
for (const row of card.crossref ?? []) {
|
|
79
|
-
if (row.person)
|
|
80
|
-
out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}
|
|
82
|
+
if (row.person) {
|
|
83
|
+
out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
|
|
84
|
+
}
|
|
81
85
|
}
|
|
82
86
|
if (card.expectedPayoff)
|
|
83
|
-
out.push(dim(` Worth trying · ${card.expectedPayoff}
|
|
87
|
+
out.push(dim(` Worth trying · ${card.expectedPayoff}`, styled));
|
|
84
88
|
for (const source of card.sources ?? []) {
|
|
85
|
-
if (source.title)
|
|
86
|
-
out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}
|
|
89
|
+
if (source.title) {
|
|
90
|
+
out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`, styled));
|
|
91
|
+
}
|
|
87
92
|
}
|
|
88
93
|
return out;
|
|
89
94
|
}
|
|
90
|
-
function renderSignal(signal) {
|
|
95
|
+
function renderSignal(signal, styled) {
|
|
91
96
|
const out = [` ⏺ ${signal.title ?? "untitled"} · ${dayStamp(signal.gatheredAt)}`];
|
|
92
97
|
if (signal.synopsis)
|
|
93
|
-
out.push(dim(` ${signal.synopsis}
|
|
98
|
+
out.push(dim(` ${signal.synopsis}`, styled));
|
|
94
99
|
const foot = [
|
|
95
100
|
signal.source,
|
|
96
101
|
signal.evidenceStrength ?? undefined,
|
|
@@ -99,7 +104,7 @@ function renderSignal(signal) {
|
|
|
99
104
|
signal.url,
|
|
100
105
|
].filter((part) => Boolean(part));
|
|
101
106
|
if (foot.length > 0)
|
|
102
|
-
out.push(dim(` ${foot.join(" · ")}
|
|
107
|
+
out.push(dim(` ${foot.join(" · ")}`, styled));
|
|
103
108
|
return out;
|
|
104
109
|
}
|
|
105
110
|
/**
|
|
@@ -164,9 +169,6 @@ export function shortId(id) {
|
|
|
164
169
|
function dayStamp(iso) {
|
|
165
170
|
return iso ? iso.slice(0, 10) : "";
|
|
166
171
|
}
|
|
167
|
-
function dim(text) {
|
|
168
|
-
return `${DIM}${text}${RESET}`;
|
|
169
|
-
}
|
|
170
172
|
function indent(text) {
|
|
171
173
|
return ` ${text}`;
|
|
172
174
|
}
|
package/dist/commands/scout.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* as the page is; moving a card is super_admin whichever surface asks, and a
|
|
17
17
|
* refusal never costs a person the board.
|
|
18
18
|
*/
|
|
19
|
-
import { writeLine } from "./cli-io.js";
|
|
19
|
+
import { colorEnabled, writeLine } from "./cli-io.js";
|
|
20
20
|
import { renderScoutBoard, resolveExperimentRef, refusalSentence, shortId, truncationLines, } from "./scout-render.js";
|
|
21
21
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
|
|
22
22
|
/** The dashboard route caps nothing here, but a board read should never hang a shell. */
|
|
@@ -49,7 +49,7 @@ export async function runScout(command, io) {
|
|
|
49
49
|
writeLine(io.stdout, JSON.stringify({ ok: true, audience: payload.audience ?? null, windowDays: payload.windowDays ?? board.windowDays ?? null, board, lines }));
|
|
50
50
|
}
|
|
51
51
|
else {
|
|
52
|
-
for (const line of renderScoutBoard(payload))
|
|
52
|
+
for (const line of renderScoutBoard(payload, colorEnabled(io)))
|
|
53
53
|
writeLine(io.stdout, line);
|
|
54
54
|
}
|
|
55
55
|
writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
|
|
@@ -78,7 +78,7 @@ export async function runScout(command, io) {
|
|
|
78
78
|
}
|
|
79
79
|
else {
|
|
80
80
|
writeLine(io.stderr, sentence);
|
|
81
|
-
for (const line of renderScoutBoard(payload))
|
|
81
|
+
for (const line of renderScoutBoard(payload, colorEnabled(io)))
|
|
82
82
|
writeLine(io.stdout, line);
|
|
83
83
|
}
|
|
84
84
|
return 1;
|
|
@@ -119,7 +119,7 @@ export async function runScout(command, io) {
|
|
|
119
119
|
if (applied.httpStatus === 403) {
|
|
120
120
|
writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
|
|
121
121
|
}
|
|
122
|
-
for (const line of renderScoutBoard(payload))
|
|
122
|
+
for (const line of renderScoutBoard(payload, colorEnabled(io)))
|
|
123
123
|
writeLine(io.stdout, line);
|
|
124
124
|
return 1;
|
|
125
125
|
}
|
|
@@ -71,9 +71,16 @@ export async function runSessions(command, io) {
|
|
|
71
71
|
reason: sidecar.skipped_reason,
|
|
72
72
|
})),
|
|
73
73
|
}));
|
|
74
|
+
// BLI-3483: the scanners have always counted the folders and files they
|
|
75
|
+
// could not read, and this command has always thrown those counts away
|
|
76
|
+
// except for one field in `--json`. So "No sessions observed" was printed on
|
|
77
|
+
// a machine where the scan had been blocked from looking — the operator's
|
|
78
|
+
// one diagnostic tool answering the question with the failure removed.
|
|
79
|
+
const readFailures = countReadFailures(codex, claude);
|
|
74
80
|
if (command.json) {
|
|
75
81
|
writeLine(io.stdout, JSON.stringify({
|
|
76
82
|
window,
|
|
83
|
+
read_failures: readFailures,
|
|
77
84
|
...(codex
|
|
78
85
|
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
79
86
|
: {}),
|
|
@@ -101,10 +108,47 @@ export async function runSessions(command, io) {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
if (codexRows.length === 0 && claudeRows.length === 0) {
|
|
104
|
-
writeLine(io.stdout,
|
|
111
|
+
writeLine(io.stdout, readFailures.total > 0
|
|
112
|
+
? `No sessions listed, but ${readFailures.total} read failure(s) mean the scan could not see everywhere — this is not proof there were none.`
|
|
113
|
+
: "No sessions observed in the scan window.");
|
|
114
|
+
}
|
|
115
|
+
if (readFailures.total > 0) {
|
|
116
|
+
for (const line of readFailureLines(readFailures)) {
|
|
117
|
+
writeLine(io.stdout, line);
|
|
118
|
+
}
|
|
105
119
|
}
|
|
106
120
|
return 0;
|
|
107
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Everything the two scanners looked at and could not read. Counts only — the
|
|
124
|
+
* project-dir slug encodes a local path and never leaves this machine.
|
|
125
|
+
*/
|
|
126
|
+
function countReadFailures(codex, claude) {
|
|
127
|
+
const failures = {
|
|
128
|
+
codex_directory_read_failed: codex?.directory_read_failed_count ?? 0,
|
|
129
|
+
codex_stat_failed: codex?.stat_failed_count ?? 0,
|
|
130
|
+
codex_secret_path_skipped: codex?.secret_path_skipped_count ?? 0,
|
|
131
|
+
claude_project_dir_read_failed: claude?.project_dir_read_failed_count ?? 0,
|
|
132
|
+
claude_project_dirs_skipped: claude?.project_dirs_skipped ?? 0,
|
|
133
|
+
claude_session_stat_failed: claude?.session_stat_failed_count ?? 0,
|
|
134
|
+
claude_sidecar_dir_read_failed: claude?.sidecar_dir_read_failed_count ?? 0,
|
|
135
|
+
claude_sidecar_stat_failed: claude?.sidecar_stat_failed_count ?? 0,
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
total: Object.values(failures).reduce((sum, count) => sum + count, 0),
|
|
139
|
+
...failures,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function readFailureLines(failures) {
|
|
143
|
+
const named = Object.entries(failures)
|
|
144
|
+
.filter(([field, count]) => field !== "total" && Number(count) > 0)
|
|
145
|
+
.map(([field, count]) => ` ${field}: ${count}`);
|
|
146
|
+
return [
|
|
147
|
+
`read failures during this scan (${failures.total}) — sessions under these are not listed:`,
|
|
148
|
+
...named,
|
|
149
|
+
" a permission wall is the usual cause; on macOS grant Full Disk Access, on Windows check the folder ACL",
|
|
150
|
+
];
|
|
151
|
+
}
|
|
108
152
|
/** `--all`, an explicit `--since-days` capped at pairing, or the default window. */
|
|
109
153
|
async function sessionsScanWindow(command, now) {
|
|
110
154
|
if (command.all) {
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* Every one of these is defensive about shape on purpose. These bodies come off
|
|
9
9
|
* the wire from a dashboard that may be a release ahead or behind the installed
|
|
10
10
|
* CLI, and a missing field should cost one line of output, not the command.
|
|
11
|
+
*
|
|
12
|
+
* Columns are padded in SCREEN CELLS, not UTF-16 code units (BLI-3482) — a
|
|
13
|
+
* display name written in another script is exactly the row where `padEnd`
|
|
14
|
+
* silently stops lining up.
|
|
11
15
|
*/
|
|
16
|
+
import { padEndDisplay } from "./text-width.js";
|
|
12
17
|
const INDENT = " ";
|
|
13
18
|
function text(value, fallback = "—") {
|
|
14
19
|
if (typeof value === "string" && value.trim())
|
|
@@ -82,7 +87,7 @@ export function renderTeamMembers(body) {
|
|
|
82
87
|
for (const person of people) {
|
|
83
88
|
const standing = text(person.standing, "");
|
|
84
89
|
const name = text(person.displayName, text(person.email));
|
|
85
|
-
lines.push(`${INDENT}${name
|
|
90
|
+
lines.push(`${INDENT}${padEndDisplay(name, 28)} ${padEndDisplay(text(person.role, "—"), 20)} ${standing}`);
|
|
86
91
|
if (typeof person.userId === "string") {
|
|
87
92
|
lines.push(`${INDENT}${INDENT}id ${person.userId}`);
|
|
88
93
|
}
|
|
@@ -101,7 +106,7 @@ export function renderSwitches(body) {
|
|
|
101
106
|
const options = Array.isArray(entry.options)
|
|
102
107
|
? entry.options.map((option) => text(record(option).value)).join(" | ")
|
|
103
108
|
: "";
|
|
104
|
-
return `${INDENT}${text(entry.key)
|
|
109
|
+
return `${INDENT}${padEndDisplay(text(entry.key), 24)} ${padEndDisplay(text(entry.value), 12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
|
|
105
110
|
});
|
|
106
111
|
if (typeof body.readFailureReason === "string") {
|
|
107
112
|
lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
|
|
@@ -114,7 +119,7 @@ export function renderModelRouting(body) {
|
|
|
114
119
|
`${INDENT}memory ${text(body.memoryEffectiveLabel)} (${text(body.memorySource)}${typeof body.memoryVia === "string" ? ` via ${body.memoryVia}` : ""})`,
|
|
115
120
|
];
|
|
116
121
|
for (const slot of list(body.displaySlots)) {
|
|
117
|
-
lines.push(`${INDENT}${text(slot.name)
|
|
122
|
+
lines.push(`${INDENT}${padEndDisplay(text(slot.name), 24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
|
|
118
123
|
}
|
|
119
124
|
if (typeof body.readFailureReason === "string") {
|
|
120
125
|
lines.push(`${INDENT}settings could not be read: ${body.readFailureReason}`);
|
|
@@ -132,6 +137,6 @@ export function renderEnvBlobs(body) {
|
|
|
132
137
|
const blobs = list(body.blobs);
|
|
133
138
|
if (blobs.length === 0)
|
|
134
139
|
return [`${INDENT}no env files stored`];
|
|
135
|
-
return blobs.map((blob) => `${INDENT}${text(blob.project)
|
|
140
|
+
return blobs.map((blob) => `${INDENT}${padEndDisplay(text(blob.project), 12)} ${padEndDisplay(text(blob.file_name), 16)} ` +
|
|
136
141
|
`updated ${text(blob.updated_at)} id ${text(blob.id)}`);
|
|
137
142
|
}
|
|
@@ -213,6 +213,12 @@ async function setModels(command, tower, io) {
|
|
|
213
213
|
return 0;
|
|
214
214
|
}
|
|
215
215
|
writeLine(io.stdout, `Saved: ${asList(body.saved).join(", ") || "nothing"}.`);
|
|
216
|
+
// BLI-3481: the save succeeded but the belt pre-flight could not reach the
|
|
217
|
+
// provider, so nobody has checked that the model just chosen will accept
|
|
218
|
+
// Tower's tool belt. A bare "Saved." there would be a silent success.
|
|
219
|
+
if (typeof body.warning === "string" && body.warning.length > 0) {
|
|
220
|
+
writeLine(io.stderr, body.warning);
|
|
221
|
+
}
|
|
216
222
|
return 0;
|
|
217
223
|
}
|
|
218
224
|
// ── env files ─────────────────────────────────────────────────────────
|
|
@@ -239,8 +245,9 @@ async function setEnvBlob(command, tower, io) {
|
|
|
239
245
|
const failure = {
|
|
240
246
|
reason: "content_not_piped",
|
|
241
247
|
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
|
-
"(
|
|
248
|
+
"`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin`. " +
|
|
249
|
+
"On Windows use PowerShell 7 (`pwsh`): `Get-Content <path> -Raw | cockpit settings env set …` — " +
|
|
250
|
+
"Windows PowerShell 5.1 turns non-ASCII into `?` on pipes.",
|
|
244
251
|
};
|
|
245
252
|
return writeCommandFailure(io, command.json, failure);
|
|
246
253
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a string is ON SCREEN, in terminal cells (BLI-3482).
|
|
3
|
+
*
|
|
4
|
+
* `"".length` counts UTF-16 code units, which is the wrong number twice over
|
|
5
|
+
* for a column: an emoji costs two units and draws two cells, a CJK ideograph
|
|
6
|
+
* costs one unit and draws two, and a combining accent costs one unit and draws
|
|
7
|
+
* nothing. Padding by `.length` therefore ragged-edges any table whose cells are
|
|
8
|
+
* not plain ASCII — the failure a person sees is a column that no longer lines
|
|
9
|
+
* up, on exactly the rows that carry a name or a title in another script.
|
|
10
|
+
*
|
|
11
|
+
* This is a deliberately small wcwidth, not the real one, and its limits are
|
|
12
|
+
* honest:
|
|
13
|
+
*
|
|
14
|
+
* - The wide ranges below are the common East Asian Wide/Fullwidth blocks plus
|
|
15
|
+
* the main emoji planes. Ambiguous-width characters (box drawing, `⏺`, Greek,
|
|
16
|
+
* Cyrillic) are counted as ONE cell, which is what a Western-locale terminal
|
|
17
|
+
* draws; a CJK-locale terminal may draw some of them as two.
|
|
18
|
+
* - A ZWJ emoji sequence (👨👩👧) is measured as the sum of its parts, so it
|
|
19
|
+
* over-counts on terminals that draw the whole cluster in two cells. Nothing
|
|
20
|
+
* here inspects grapheme clusters; that needs `Intl.Segmenter` and a real
|
|
21
|
+
* emoji table, which is more machinery than a column deserves.
|
|
22
|
+
* - U+FE0F (emoji presentation) is counted as zero, so a text-default symbol
|
|
23
|
+
* promoted to emoji presentation is under-counted by one.
|
|
24
|
+
*
|
|
25
|
+
* Every one of those errors costs alignment, never content: nothing in this
|
|
26
|
+
* file may drop, cut or reorder text.
|
|
27
|
+
*/
|
|
28
|
+
/** SGR colour sequences occupy no cells, so they are removed before counting. */
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const ANSI_SGR = /\x1b\[[0-9;]*m/gu;
|
|
31
|
+
/** Zero-cell code points: combining marks, joiners, variation selectors. */
|
|
32
|
+
const ZERO_WIDTH = [
|
|
33
|
+
[0x0300, 0x036f], // combining diacritical marks
|
|
34
|
+
[0x0483, 0x0489],
|
|
35
|
+
[0x0591, 0x05bd],
|
|
36
|
+
[0x0610, 0x061a],
|
|
37
|
+
[0x064b, 0x065f],
|
|
38
|
+
[0x0670, 0x0670],
|
|
39
|
+
[0x06d6, 0x06dc],
|
|
40
|
+
[0x0e31, 0x0e31],
|
|
41
|
+
[0x0e34, 0x0e3a],
|
|
42
|
+
[0x0e47, 0x0e4e],
|
|
43
|
+
[0x1ab0, 0x1aff], // combining diacritical marks extended
|
|
44
|
+
[0x1dc0, 0x1dff], // combining diacritical marks supplement
|
|
45
|
+
[0x200b, 0x200f], // zero-width space through RTL mark (incl. ZWNJ, ZWJ)
|
|
46
|
+
[0x20d0, 0x20f0], // combining marks for symbols
|
|
47
|
+
[0xfe00, 0xfe0f], // variation selectors
|
|
48
|
+
[0xfe20, 0xfe2f], // combining half marks
|
|
49
|
+
[0xfeff, 0xfeff], // byte-order mark
|
|
50
|
+
];
|
|
51
|
+
/** Two-cell code points: the common Wide / Fullwidth blocks and emoji planes. */
|
|
52
|
+
const WIDE = [
|
|
53
|
+
[0x1100, 0x115f], // Hangul Jamo
|
|
54
|
+
[0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
|
|
55
|
+
[0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
|
|
56
|
+
[0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
|
|
57
|
+
[0x4e00, 0x9fff], // CJK Unified Ideographs
|
|
58
|
+
[0xa000, 0xa4cf], // Yi
|
|
59
|
+
[0xa960, 0xa97f], // Hangul Jamo Extended-A
|
|
60
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
61
|
+
[0xf900, 0xfaff], // CJK compatibility ideographs
|
|
62
|
+
[0xfe10, 0xfe19], // vertical forms
|
|
63
|
+
[0xfe30, 0xfe6f], // CJK compatibility forms
|
|
64
|
+
[0xff00, 0xff60], // fullwidth forms
|
|
65
|
+
[0xffe0, 0xffe6], // fullwidth signs
|
|
66
|
+
[0x1f300, 0x1f64f], // misc symbols and pictographs, emoticons
|
|
67
|
+
[0x1f680, 0x1f6ff], // transport and map symbols
|
|
68
|
+
[0x1f900, 0x1f9ff], // supplemental symbols and pictographs
|
|
69
|
+
[0x20000, 0x3fffd], // CJK Unified Ideographs Extensions B onward
|
|
70
|
+
];
|
|
71
|
+
function inRanges(code, ranges) {
|
|
72
|
+
for (const [low, high] of ranges) {
|
|
73
|
+
if (code < low)
|
|
74
|
+
return false; // ranges are ascending
|
|
75
|
+
if (code <= high)
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
/** Cells one code point draws: 0 for combining/invisible, 2 for wide, else 1. */
|
|
81
|
+
export function codePointWidth(code) {
|
|
82
|
+
if (code === 0x0a || code === 0x0d)
|
|
83
|
+
return 0;
|
|
84
|
+
if (code < 0x20 || (code >= 0x7f && code < 0xa0))
|
|
85
|
+
return 0; // control characters
|
|
86
|
+
if (inRanges(code, ZERO_WIDTH))
|
|
87
|
+
return 0;
|
|
88
|
+
if (inRanges(code, WIDE))
|
|
89
|
+
return 2;
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
/** Cells a string draws, ignoring any SGR colour sequences inside it. */
|
|
93
|
+
export function displayWidth(text) {
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (const character of text.replace(ANSI_SGR, "")) {
|
|
96
|
+
total += codePointWidth(character.codePointAt(0) ?? 0);
|
|
97
|
+
}
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `padEnd` that counts cells instead of code units. Like `padEnd`, a string
|
|
102
|
+
* already at or past the column is returned untouched — a column is a minimum,
|
|
103
|
+
* never a limit, because cutting a cell to fit loses content.
|
|
104
|
+
*/
|
|
105
|
+
export function padEndDisplay(text, width) {
|
|
106
|
+
const missing = width - displayWidth(text);
|
|
107
|
+
return missing > 0 ? `${text}${" ".repeat(missing)}` : text;
|
|
108
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - **stdout belongs to `--json`.** Every operational line goes to stderr, which
|
|
18
18
|
* launchd captures; a `--json` run puts exactly one object on stdout.
|
|
19
19
|
*/
|
|
20
|
-
import { writeLine } from "./cli-io.js";
|
|
20
|
+
import { readPipedText, writeLine } from "./cli-io.js";
|
|
21
21
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
|
|
22
22
|
/** Client-side ceiling. These routes are small reads and writes, not turns. */
|
|
23
23
|
const REQUEST_DEADLINE_MS = 30_000;
|
|
@@ -101,12 +101,11 @@ export function parseModelKey(key) {
|
|
|
101
101
|
}
|
|
102
102
|
return { provider: key.slice(0, separator), model: key.slice(separator + 1) };
|
|
103
103
|
}
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Reads piped stdin whole, BOM-stripped (BLI-3480 — PowerShell redirection
|
|
106
|
+
* writes one, and stored verbatim it makes the first env line unreachable).
|
|
107
|
+
* Used only for env content, which is never echoed.
|
|
108
|
+
*/
|
|
105
109
|
export async function readAllStdin(stream) {
|
|
106
|
-
stream
|
|
107
|
-
let text = "";
|
|
108
|
-
for await (const chunk of stream) {
|
|
109
|
-
text += chunk;
|
|
110
|
-
}
|
|
111
|
-
return text;
|
|
110
|
+
return readPipedText(stream);
|
|
112
111
|
}
|
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
* summarises, reorders or paraphrases — it wraps, and it strips the emphasis
|
|
11
11
|
* markers a reader did not ask for. `--markdown` bypasses this file entirely
|
|
12
12
|
* and prints the server's bytes, which is what lets this layout be opinionated.
|
|
13
|
+
*
|
|
14
|
+
* Two things arrive as arguments rather than being read from the process, so
|
|
15
|
+
* these functions stay pure and their tests stay deterministic (BLI-3482):
|
|
16
|
+
* `width` (the wrap column) and `styled` (whether stdout is a terminal that
|
|
17
|
+
* asked for colour — `workbook.ts` decides it once with `colorEnabled(io)`).
|
|
18
|
+
* Columns are measured in SCREEN CELLS via `displayWidth`, not in UTF-16 code
|
|
19
|
+
* units, so a table whose cells carry CJK or emoji still lines up.
|
|
13
20
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
import { dim } from "./cli-io.js";
|
|
22
|
+
import { displayWidth, padEndDisplay } from "./text-width.js";
|
|
16
23
|
/** One compartment per project, its documents underneath, addressed as typed. */
|
|
17
|
-
export function renderShelf(projects, width) {
|
|
24
|
+
export function renderShelf(projects, width, styled) {
|
|
18
25
|
const docs = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
|
|
19
26
|
const out = [
|
|
20
27
|
`WORKBOOK · ${projects.length} project${projects.length === 1 ? "" : "s"} · ${docs} document${docs === 1 ? "" : "s"}`,
|
|
@@ -27,25 +34,25 @@ export function renderShelf(projects, width) {
|
|
|
27
34
|
out.push(` ⏺ cockpit workbook ${project.slug ?? ""} ${doc.slug ?? ""}`);
|
|
28
35
|
out.push(` ${doc.title ?? ""} · ${doc.kind ?? ""}`);
|
|
29
36
|
if (doc.line) {
|
|
30
|
-
out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}
|
|
37
|
+
out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`, styled)));
|
|
31
38
|
}
|
|
32
|
-
out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}
|
|
39
|
+
out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`, styled));
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
return out;
|
|
36
43
|
}
|
|
37
44
|
/** The document's own header, then its blocks laid out for `width`. */
|
|
38
|
-
export function renderDocText(payload, markdown, width) {
|
|
45
|
+
export function renderDocText(payload, markdown, width, styled) {
|
|
39
46
|
const doc = payload.doc ?? {};
|
|
40
47
|
const out = [];
|
|
41
48
|
if (doc.title)
|
|
42
49
|
out.push(...wrap(doc.title.toUpperCase(), width));
|
|
43
50
|
const meta = [doc.kind, payload.project?.title, doc.author, doc.date].filter(Boolean);
|
|
44
51
|
if (meta.length > 0)
|
|
45
|
-
out.push(...wrap(meta.join(" · "), width).map((line) => dim(line)));
|
|
52
|
+
out.push(...wrap(meta.join(" · "), width).map((line) => dim(line, styled)));
|
|
46
53
|
if (out.length > 0)
|
|
47
54
|
out.push("");
|
|
48
|
-
out.push(...renderMarkdownText(markdown, width));
|
|
55
|
+
out.push(...renderMarkdownText(markdown, width, styled));
|
|
49
56
|
return out;
|
|
50
57
|
}
|
|
51
58
|
/**
|
|
@@ -54,7 +61,7 @@ export function renderDocText(payload, markdown, width) {
|
|
|
54
61
|
* paragraphs — so there is no "unknown block" case to guess at; anything else
|
|
55
62
|
* falls through as a wrapped paragraph rather than being dropped.
|
|
56
63
|
*/
|
|
57
|
-
export function renderMarkdownText(markdown, width) {
|
|
64
|
+
export function renderMarkdownText(markdown, width, styled) {
|
|
58
65
|
const out = [];
|
|
59
66
|
for (const block of markdown.split("\n\n")) {
|
|
60
67
|
const text = block.trim();
|
|
@@ -80,7 +87,7 @@ export function renderMarkdownText(markdown, width) {
|
|
|
80
87
|
.map((line) => plain(line.replace(/^>\s?/, "")))
|
|
81
88
|
.join(" ")
|
|
82
89
|
.trim();
|
|
83
|
-
out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}
|
|
90
|
+
out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`, styled)));
|
|
84
91
|
continue;
|
|
85
92
|
}
|
|
86
93
|
if (text.startsWith("- ")) {
|
|
@@ -102,6 +109,11 @@ export function renderMarkdownText(markdown, width) {
|
|
|
102
109
|
* width, each row is printed as `header: cell` lines instead — narrower, and
|
|
103
110
|
* still every cell. Dropping columns to fit is never an option: a table with a
|
|
104
111
|
* column missing looks complete and is not.
|
|
112
|
+
*
|
|
113
|
+
* Column sizes are SCREEN CELLS (BLI-3482). `.length` counts UTF-16 units, so a
|
|
114
|
+
* name in Japanese under-padded by one cell per character and a column of
|
|
115
|
+
* emoji over-padded by one — the fits/does-not-fit decision was measured in
|
|
116
|
+
* the same wrong unit, which is how a table that fits chose the narrow layout.
|
|
105
117
|
*/
|
|
106
118
|
function renderTable(block, width) {
|
|
107
119
|
const rows = block
|
|
@@ -121,12 +133,12 @@ function renderTable(block, width) {
|
|
|
121
133
|
const columns = Math.max(...rows.map((cells) => cells.length));
|
|
122
134
|
const widths = [];
|
|
123
135
|
for (let index = 0; index < columns; index += 1) {
|
|
124
|
-
widths.push(Math.max(...rows.map((cells) => (cells[index] ?? "")
|
|
136
|
+
widths.push(Math.max(...rows.map((cells) => displayWidth(cells[index] ?? ""))));
|
|
125
137
|
}
|
|
126
138
|
const tableWidth = widths.reduce((sum, value) => sum + value, 0) + 2 * (columns - 1);
|
|
127
139
|
if (tableWidth <= width) {
|
|
128
140
|
return rows.map((cells) => cells
|
|
129
|
-
.map((cell, index) => cell
|
|
141
|
+
.map((cell, index) => padEndDisplay(cell, index === columns - 1 ? 0 : (widths[index] ?? 0)))
|
|
130
142
|
.join(" ")
|
|
131
143
|
.trimEnd());
|
|
132
144
|
}
|
|
@@ -160,7 +172,10 @@ export function sectionSlice(markdown, sections, sectionId) {
|
|
|
160
172
|
return blocks.slice(start, end).join("\n\n").trim();
|
|
161
173
|
}
|
|
162
174
|
// ----------------------------------------------------------------- small parts
|
|
163
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Greedy word wrap, measured in screen cells. A word longer than the column
|
|
177
|
+
* keeps its own line, uncut.
|
|
178
|
+
*/
|
|
164
179
|
export function wrap(text, width) {
|
|
165
180
|
const limit = Math.max(20, width);
|
|
166
181
|
const words = text.split(/\s+/u).filter(Boolean);
|
|
@@ -168,17 +183,22 @@ export function wrap(text, width) {
|
|
|
168
183
|
return [];
|
|
169
184
|
const lines = [];
|
|
170
185
|
let line = "";
|
|
186
|
+
let lineWidth = 0;
|
|
171
187
|
for (const word of words) {
|
|
188
|
+
const wordWidth = displayWidth(word);
|
|
172
189
|
if (!line) {
|
|
173
190
|
line = word;
|
|
191
|
+
lineWidth = wordWidth;
|
|
174
192
|
continue;
|
|
175
193
|
}
|
|
176
|
-
if (
|
|
194
|
+
if (lineWidth + 1 + wordWidth <= limit) {
|
|
177
195
|
line = `${line} ${word}`;
|
|
196
|
+
lineWidth += 1 + wordWidth;
|
|
178
197
|
}
|
|
179
198
|
else {
|
|
180
199
|
lines.push(line);
|
|
181
200
|
line = word;
|
|
201
|
+
lineWidth = wordWidth;
|
|
182
202
|
}
|
|
183
203
|
}
|
|
184
204
|
lines.push(line);
|
|
@@ -190,7 +210,4 @@ function plain(text) {
|
|
|
190
210
|
.replace(/\*\*(.+?)\*\*/gu, "$1")
|
|
191
211
|
.replace(/\*(.+?)\*/gu, "$1")
|
|
192
212
|
.replace(/\\\|/gu, "|");
|
|
193
|
-
}
|
|
194
|
-
function dim(text) {
|
|
195
|
-
return `${DIM}${text}${RESET}`;
|
|
196
213
|
}
|