@bli-cockpit/cli 0.2.287 → 0.2.288
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/commands/agreements-render.js +61 -0
- package/dist/commands/agreements.js +196 -0
- package/dist/commands/books-files.js +159 -0
- package/dist/commands/books-usage.js +14 -0
- package/dist/commands/books.js +214 -0
- package/dist/commands/docs-share.js +21 -11
- package/dist/commands/local-args-tower-agreements.js +80 -0
- package/dist/commands/local-args-tower-docs-msg.js +17 -5
- package/dist/commands/local-args-tower.js +2 -0
- package/dist/commands/local-args.js +4 -1
- package/dist/commands/local-command-shapes-agreements.js +5 -0
- package/dist/commands/local-help-agreements.js +24 -0
- package/dist/commands/local-help-commands-tower-ops.js +3 -3
- package/dist/commands/local-help-commands-tower.js +7 -5
- package/dist/commands/local-help-commands.js +5 -3
- package/dist/commands/local-help.js +4 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/commands/speed-seen.js +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `cockpit agreements` prints the doors' answers as plain text. The shapes copy
|
|
3
|
+
* `apps/dashboard/src/lib/agreements/contracts.ts` (this package does not import the dashboard); only the fields
|
|
4
|
+
* printed here are named.
|
|
5
|
+
*/
|
|
6
|
+
const STATUS_WORDS = { open: "waiting for your signature", signed: "signed", cancelled: "cancelled" };
|
|
7
|
+
/** One request in `cockpit agreements`: what, its state, and the id to show it. */
|
|
8
|
+
export function rowLines(row) {
|
|
9
|
+
const when = row.status === "signed" && row.signed_at ? `, ${row.signed_at.slice(0, 10)}` : row.status === "open" && row.due_at ? `, due ${row.due_at.slice(0, 10)}` : "";
|
|
10
|
+
return [`${row.document_title} (${row.version_label}): ${STATUS_WORDS[row.status] ?? row.status}${when}`, ` cockpit agreements show ${row.request_id}`];
|
|
11
|
+
}
|
|
12
|
+
/** A box as it was answered once signed; empty before then, because a box is never pre-ticked. */
|
|
13
|
+
function boxLine(box, ticked) {
|
|
14
|
+
return ` ${ticked ? "[x]" : "[ ]"} ${box.wording} (${box.required ? "required" : "optional"})`;
|
|
15
|
+
}
|
|
16
|
+
/** `cockpit agreements show`: the exact text, the boxes, and the record once signed, or where to sign while open. */
|
|
17
|
+
export function agreementLines(agreement, dashboardUrl) {
|
|
18
|
+
const lines = [
|
|
19
|
+
`${agreement.document_title} (${agreement.version_label})`,
|
|
20
|
+
`Status: ${STATUS_WORDS[agreement.status] ?? agreement.status}${agreement.status === "open" && agreement.due_at ? `, due ${agreement.due_at.slice(0, 10)}` : ""}`,
|
|
21
|
+
`Published ${agreement.published_at}. Text fingerprint (SHA-256): ${agreement.text_sha256}`,
|
|
22
|
+
"",
|
|
23
|
+
agreement.body,
|
|
24
|
+
"",
|
|
25
|
+
];
|
|
26
|
+
const signature = agreement.signature;
|
|
27
|
+
if (agreement.consent_boxes.length > 0) {
|
|
28
|
+
lines.push("Boxes:", ...agreement.consent_boxes.map((box) => boxLine(box, signature?.consents[box.key] === true)), "");
|
|
29
|
+
}
|
|
30
|
+
if (signature) {
|
|
31
|
+
lines.push("Signing record:", ` Signed by: ${signature.typed_name}${signature.signer_email ? ` <${signature.signer_email}>` : ""}`, ` Signed at: ${signature.signed_at}`, ` Identified by: ${signature.identity_method === "tower_session+email_code" ? "Tower sign-in and a one-time code sent by email" : signature.identity_method}`, ` Event number in Tower's signing log: ${signature.event_seq}`, `Your copy: cockpit agreements copy ${agreement.request_id} --out <file>`);
|
|
32
|
+
}
|
|
33
|
+
else if (agreement.status === "open") {
|
|
34
|
+
lines.push(`Sign it in Tower in your browser: ${dashboardUrl}/agreements/${agreement.request_id}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
lines.push("This request was cancelled. Nothing was signed.");
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
function flagWords(doc) {
|
|
42
|
+
const flags = [doc.needs_countersign ? "needs a countersignature" : "", doc.required_for_access ? "required for Tower access" : "", doc.needs_handwritten_confirmation ? "needs a handwritten confirmation" : ""].filter(Boolean);
|
|
43
|
+
return flags.length > 0 ? ` (${flags.join("; ")})` : "";
|
|
44
|
+
}
|
|
45
|
+
/** `cockpit agreements documents`: each document, its versions, and who was asked to sign each. */
|
|
46
|
+
export function documentLines(doc) {
|
|
47
|
+
const lines = [`${doc.title} [${doc.slug}]${flagWords(doc)}`, ` document ${doc.document_id}`];
|
|
48
|
+
if (doc.versions.length === 0)
|
|
49
|
+
lines.push(" No versions yet.");
|
|
50
|
+
for (const version of doc.versions) {
|
|
51
|
+
const state = version.published_at ? `published ${version.published_at.slice(0, 10)}` : "draft, not published";
|
|
52
|
+
const boxes = version.consent_boxes.length === 0 ? "no boxes" : `${version.consent_boxes.length} box${version.consent_boxes.length === 1 ? "" : "es"}`;
|
|
53
|
+
lines.push(` ${version.label} (${version.language}): ${state}, ${boxes}`, ` version ${version.version_id}`);
|
|
54
|
+
for (const asked of version.requests) {
|
|
55
|
+
const who = asked.signer_name ?? asked.signer_user_id;
|
|
56
|
+
const due = asked.status === "open" && asked.due_at ? `, due ${asked.due_at.slice(0, 10)}` : "";
|
|
57
|
+
lines.push(` - ${who}: ${asked.status}${due} (request ${asked.request_id})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit agreements` (docs/architecture/agreements.md): agreements to sign, from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Plain words: a founder publishes a document as a fixed version and asks named people to sign it. Each person reads it
|
|
5
|
+
* and signs in Tower in their browser; this command reads their own requests and the signed copy, and lets a founder
|
|
6
|
+
* draft a document, ask someone to sign a published version, or cancel a request.
|
|
7
|
+
*
|
|
8
|
+
* Seven verbs, each one `/api/agreements` door on this machine's device token, the same doors the web page, the MCP
|
|
9
|
+
* twins (`agreements_*`) and JARVIS use. The door's own words are printed; `--json` carries the raw body on stdout and
|
|
10
|
+
* nothing else. A draft arrives as JSON on stdin. There is deliberately no verb to ask for a signing code, to sign, or
|
|
11
|
+
* to publish: those are a person's own act in the browser, and their doors refuse a device token (founders, 2026-10-28).
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, fetchAgentDoorFile, openAgentDoor } from "./agent-door.js";
|
|
16
|
+
import { agreementLines, documentLines, rowLines } from "./agreements-render.js";
|
|
17
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
18
|
+
const TAG = "[agreements cli]";
|
|
19
|
+
const READ_DEADLINE_MS = 30_000;
|
|
20
|
+
const WRITE_DEADLINE_MS = 55_000;
|
|
21
|
+
/** A document's text is at most 200,000 characters (`MAX_BODY_CHARS`); the boxes and names ride on top. */
|
|
22
|
+
const DRAFT_MAX_CHARS = 300_000;
|
|
23
|
+
export async function runAgreements(command, io) {
|
|
24
|
+
let door;
|
|
25
|
+
try {
|
|
26
|
+
door = await openAgentDoor("agreements", command, io);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
|
|
30
|
+
writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "not_paired" })}`);
|
|
31
|
+
if (command.json)
|
|
32
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail }));
|
|
33
|
+
else
|
|
34
|
+
writeLine(io.stderr, detail);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
switch (command.action) {
|
|
38
|
+
case "list": return list(door);
|
|
39
|
+
case "show": return show(command, door);
|
|
40
|
+
case "copy": return copy(command, door);
|
|
41
|
+
case "documents": return documents(door);
|
|
42
|
+
case "draft": return draft(door);
|
|
43
|
+
case "request": return request(command, door);
|
|
44
|
+
case "cancel": return cancel(command, door);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function list(door) {
|
|
48
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements", method: "GET", label: "agreements list", timeoutMs: READ_DEADLINE_MS });
|
|
49
|
+
if (!answer.ok)
|
|
50
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
51
|
+
const body = answer.body;
|
|
52
|
+
const open = body.agreements.filter((row) => row.status === "open").length;
|
|
53
|
+
writeLine(door.io.stderr, `${TAG} list answered ${JSON.stringify({ returned: body.agreements.length, open })}`);
|
|
54
|
+
if (door.json)
|
|
55
|
+
return emitAgentDoor(door, answer.body);
|
|
56
|
+
const out = door.io.stdout;
|
|
57
|
+
if (body.agreements.length === 0) {
|
|
58
|
+
writeLine(out, "Nothing to sign, and nothing signed yet.");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
writeLine(out, open === 0 ? "Nothing is waiting for your signature." : `${open} waiting for your signature.`);
|
|
62
|
+
for (const row of body.agreements) {
|
|
63
|
+
writeLine(out, "");
|
|
64
|
+
for (const line of rowLines(row))
|
|
65
|
+
writeLine(out, line);
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
async function show(command, door) {
|
|
70
|
+
const id = command.requestId ?? "";
|
|
71
|
+
const answer = await askAgentDoor(door, { path: `/api/agreements/${encodeURIComponent(id)}`, method: "GET", label: "agreements show", timeoutMs: READ_DEADLINE_MS });
|
|
72
|
+
if (!answer.ok)
|
|
73
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
74
|
+
const body = answer.body;
|
|
75
|
+
writeLine(door.io.stderr, `${TAG} show answered ${JSON.stringify({ request_id: body.agreement.request_id, status: body.agreement.status, boxes: body.agreement.consent_boxes.length })}`);
|
|
76
|
+
if (door.json)
|
|
77
|
+
return emitAgentDoor(door, answer.body);
|
|
78
|
+
for (const line of agreementLines(body.agreement, door.dashboardUrl))
|
|
79
|
+
writeLine(door.io.stdout, line);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/** The signed copy, as the door wrote it: to `--out`, or on stdout. Only a signed agreement has one. */
|
|
83
|
+
async function copy(command, door) {
|
|
84
|
+
const id = command.requestId ?? "";
|
|
85
|
+
const answer = await fetchAgentDoorFile(door, { path: `/api/agreements/${encodeURIComponent(id)}/copy`, label: "agreements copy", timeoutMs: READ_DEADLINE_MS });
|
|
86
|
+
if (!answer.ok)
|
|
87
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
88
|
+
const text = answer.bytes.toString("utf8");
|
|
89
|
+
if (!command.outPath) {
|
|
90
|
+
writeLine(door.io.stderr, `${TAG} copy answered ${JSON.stringify({ request_id: id, bytes: answer.bytes.length, written: false })}`);
|
|
91
|
+
if (door.json)
|
|
92
|
+
return emitAgentDoor(door, { ok: true, request_id: id, filename: answer.filename, text });
|
|
93
|
+
door.io.stdout.write(text);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const target = path.resolve(command.outPath);
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
99
|
+
await writeFile(target, answer.bytes);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return failAgentDoor(door, TAG, "copy_write_failed", `Could not write ${target}: ${detail}`);
|
|
104
|
+
}
|
|
105
|
+
writeLine(door.io.stderr, `${TAG} copy answered ${JSON.stringify({ request_id: id, bytes: answer.bytes.length, written: true })}`);
|
|
106
|
+
if (door.json)
|
|
107
|
+
return emitAgentDoor(door, { ok: true, request_id: id, path: target, bytes: answer.bytes.length });
|
|
108
|
+
writeLine(door.io.stdout, `Saved your signed copy: ${target}`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
async function documents(door) {
|
|
112
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/documents", method: "GET", label: "agreements documents", timeoutMs: READ_DEADLINE_MS });
|
|
113
|
+
if (!answer.ok)
|
|
114
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
115
|
+
const body = answer.body;
|
|
116
|
+
writeLine(door.io.stderr, `${TAG} documents answered ${JSON.stringify({ documents: body.documents.length, versions: body.documents.reduce((sum, doc) => sum + doc.versions.length, 0) })}`);
|
|
117
|
+
if (door.json)
|
|
118
|
+
return emitAgentDoor(door, answer.body);
|
|
119
|
+
if (body.documents.length === 0) {
|
|
120
|
+
writeLine(door.io.stdout, "No documents yet. Draft one with `cockpit agreements draft` (the JSON on stdin).");
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
for (const doc of body.documents) {
|
|
124
|
+
for (const line of documentLines(doc))
|
|
125
|
+
writeLine(door.io.stdout, line);
|
|
126
|
+
writeLine(door.io.stdout, "");
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
async function draft(door) {
|
|
131
|
+
const piped = await readJsonObject(door);
|
|
132
|
+
if (!piped.ok)
|
|
133
|
+
return failAgentDoor(door, TAG, piped.reason, piped.detail);
|
|
134
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/documents", method: "POST", label: "agreements draft", timeoutMs: WRITE_DEADLINE_MS, body: piped.value });
|
|
135
|
+
if (!answer.ok)
|
|
136
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
137
|
+
const body = answer.body;
|
|
138
|
+
writeLine(door.io.stderr, `${TAG} draft saved ${JSON.stringify({ document_id: body.document_id, version_id: body.version_id })}`);
|
|
139
|
+
if (door.json)
|
|
140
|
+
return emitAgentDoor(door, answer.body);
|
|
141
|
+
const out = door.io.stdout;
|
|
142
|
+
writeLine(out, "Saved the draft. Nobody can sign it until a founder publishes it.");
|
|
143
|
+
writeLine(out, ` document ${body.document_id}`);
|
|
144
|
+
writeLine(out, ` version ${body.version_id}`);
|
|
145
|
+
writeLine(out, "Publishing is done in Tower in your browser, where a founder reads the text and confirms it.");
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
async function request(command, door) {
|
|
149
|
+
const requestBody = { version_id: command.versionId, signer_user_id: command.personId, ...(command.dueAt ? { due_at: command.dueAt } : {}) };
|
|
150
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/requests", method: "POST", label: "agreements request", timeoutMs: WRITE_DEADLINE_MS, body: requestBody });
|
|
151
|
+
if (!answer.ok)
|
|
152
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
153
|
+
const body = answer.body;
|
|
154
|
+
writeLine(door.io.stderr, `${TAG} request created ${JSON.stringify({ request_id: body.request_id, due: Boolean(command.dueAt) })}`);
|
|
155
|
+
if (door.json)
|
|
156
|
+
return emitAgentDoor(door, answer.body);
|
|
157
|
+
writeLine(door.io.stdout, body.message ?? "Asked.");
|
|
158
|
+
writeLine(door.io.stdout, ` request ${body.request_id}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
async function cancel(command, door) {
|
|
162
|
+
const id = command.requestId ?? "";
|
|
163
|
+
const answer = await askAgentDoor(door, { path: `/api/agreements/requests/${encodeURIComponent(id)}/cancel`, method: "POST", label: "agreements cancel", timeoutMs: WRITE_DEADLINE_MS, body: {} });
|
|
164
|
+
if (!answer.ok)
|
|
165
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
166
|
+
const body = answer.body;
|
|
167
|
+
writeLine(door.io.stderr, `${TAG} cancelled ${JSON.stringify({ request_id: body.request_id })}`);
|
|
168
|
+
if (door.json)
|
|
169
|
+
return emitAgentDoor(door, answer.body);
|
|
170
|
+
writeLine(door.io.stdout, body.message ?? "Request cancelled.");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
/** A JSON object from stdin. A document's text never travels on the command line, and a terminal is refused instead of waited on. */
|
|
174
|
+
async function readJsonObject(door) {
|
|
175
|
+
if (isInteractiveStdin(door.io)) {
|
|
176
|
+
return { ok: false, reason: "nothing_piped", detail: "Pipe the draft in as JSON, e.g. `cockpit agreements draft < draft.json`. Nothing was saved." };
|
|
177
|
+
}
|
|
178
|
+
let text;
|
|
179
|
+
try {
|
|
180
|
+
text = await readPipedText(door.io.stdin, { maxChars: DRAFT_MAX_CHARS, overflowMessage: `The JSON on stdin is limited to ${DRAFT_MAX_CHARS} characters.` });
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
184
|
+
}
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(text);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { ok: false, reason: "invalid_json_body", detail: `stdin was not valid JSON (${text.length} characters read). Nothing was saved.` };
|
|
191
|
+
}
|
|
192
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
193
|
+
return { ok: false, reason: "invalid_json_body", detail: "stdin must be one JSON object, like {\"slug\":\"...\",\"title\":\"...\",\"label\":\"v1\",\"body\":\"...\"}. Nothing was saved." };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, value: parsed };
|
|
196
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit books export|import` (BLI-23711, PR A): a book as a folder, `<dir>/<collection>/<doc_id>.json`, each file
|
|
3
|
+
* the record's data only, pretty JSON with a trailing newline. That is the layout the Claude artifact export already
|
|
4
|
+
* writes, so the scripts that read a book's export read Tower's the same way.
|
|
5
|
+
*
|
|
6
|
+
* Export walks every collection through the list door (keyset pages of 500). Import reads the same layout and writes
|
|
7
|
+
* through the batch door, 50 writes (and at most ~3 MB) per batch, keeping doc ids and data unchanged, then prints what
|
|
8
|
+
* it wrote per collection. A doc id holding a character a file name cannot (`<>:"/\|?*%`, control characters, a
|
|
9
|
+
* trailing dot or space) is written with that character as %XX, and import decodes it back.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
14
|
+
import { writeLine } from "./cli-io.js";
|
|
15
|
+
export const BOOK_COLLECTIONS = ["meta", "timeline", "actions", "documents", "meetings", "conversations", "workstreams", "sources", "syncs"];
|
|
16
|
+
const BATCH_WRITES = 50;
|
|
17
|
+
const BATCH_CHARS = 3_000_000;
|
|
18
|
+
const TAG = "[books cli]";
|
|
19
|
+
/** One door call; a refusal keeps the door's own reason and message. */
|
|
20
|
+
export async function booksRequest(door, method, path, label, body) {
|
|
21
|
+
return askAgentDoor(door, { path, method, label: `books ${label}`, timeoutMs: 60_000, ...(body === undefined ? {} : { body }) });
|
|
22
|
+
}
|
|
23
|
+
// eslint-disable-next-line no-control-regex
|
|
24
|
+
const UNSAFE = /[<>:"/\\|?*%\u0000-\u001f]|[. ]$/g;
|
|
25
|
+
/** A doc id as a file name (without `.json`). Reversible: `%` itself is encoded. */
|
|
26
|
+
export function fileNameFor(docId) {
|
|
27
|
+
return docId.replace(UNSAFE, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
28
|
+
}
|
|
29
|
+
export function docIdFor(fileName) {
|
|
30
|
+
return fileName.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
31
|
+
}
|
|
32
|
+
/** Keys in a stable order, so two exports of the same book are byte-identical. Values are untouched. */
|
|
33
|
+
function sorted(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(sorted);
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
async function isEmptyOrMissing(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return (await readdir(dir)).length === 0;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT")
|
|
47
|
+
return true;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function exportBook(door, book, dir) {
|
|
52
|
+
if (!(await isEmptyOrMissing(dir)))
|
|
53
|
+
return failAgentDoor(door, TAG, "export_dir_not_empty", `${dir} is not empty. Export into a new or empty folder so no old record is mixed in.`);
|
|
54
|
+
const counts = {};
|
|
55
|
+
for (const collection of BOOK_COLLECTIONS) {
|
|
56
|
+
counts[collection] = 0;
|
|
57
|
+
let after = null;
|
|
58
|
+
do {
|
|
59
|
+
const query = new URLSearchParams({ collection, limit: "500" });
|
|
60
|
+
if (after !== null)
|
|
61
|
+
query.set("after", after);
|
|
62
|
+
const answer = await booksRequest(door, "GET", `/api/books/${encodeURIComponent(book)}/records?${query}`, "export");
|
|
63
|
+
if (!answer.ok)
|
|
64
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, { collection, written: counts });
|
|
65
|
+
const page = answer.body;
|
|
66
|
+
if (page.records.length > 0)
|
|
67
|
+
await mkdir(join(dir, collection), { recursive: true });
|
|
68
|
+
for (const record of page.records) {
|
|
69
|
+
await writeFile(join(dir, collection, `${fileNameFor(record.doc_id)}.json`), `${JSON.stringify(sorted(record.data), null, 2)}\n`, "utf8");
|
|
70
|
+
counts[collection] += 1;
|
|
71
|
+
}
|
|
72
|
+
after = page.next_cursor;
|
|
73
|
+
} while (after !== null);
|
|
74
|
+
}
|
|
75
|
+
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
76
|
+
console.error(`${TAG} exported ${JSON.stringify({ book, total })}`);
|
|
77
|
+
if (door.json)
|
|
78
|
+
return emitAgentDoor(door, { ok: true, book, dir, counts, total });
|
|
79
|
+
for (const collection of BOOK_COLLECTIONS)
|
|
80
|
+
writeLine(door.io.stdout, `${collection}\t${counts[collection]}`);
|
|
81
|
+
writeLine(door.io.stdout, `Exported ${total} records of ${book} to ${dir}.`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
async function readLayout(dir) {
|
|
85
|
+
const writes = [];
|
|
86
|
+
const skipped = [];
|
|
87
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
88
|
+
if (!entry.isDirectory() || !BOOK_COLLECTIONS.includes(entry.name)) {
|
|
89
|
+
skipped.push(entry.name);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const file of (await readdir(join(dir, entry.name))).sort()) {
|
|
93
|
+
if (!file.endsWith(".json")) {
|
|
94
|
+
skipped.push(`${entry.name}/${file}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const text = (await readFile(join(dir, entry.name, file), "utf8")).replace(/^/, "");
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new Error(`${entry.name}/${file} is not valid JSON.`);
|
|
104
|
+
}
|
|
105
|
+
writes.push({ op: "put", collection: entry.name, doc_id: docIdFor(file.slice(0, -".json".length)), data, merge: false });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { writes, skipped };
|
|
109
|
+
}
|
|
110
|
+
/** Batches of at most 50 writes and ~3 MB, in file order. */
|
|
111
|
+
export function batchesOf(writes) {
|
|
112
|
+
const batches = [];
|
|
113
|
+
let current = [];
|
|
114
|
+
let chars = 0;
|
|
115
|
+
for (const write of writes) {
|
|
116
|
+
const size = JSON.stringify(write).length;
|
|
117
|
+
if (current.length > 0 && (current.length >= BATCH_WRITES || chars + size > BATCH_CHARS)) {
|
|
118
|
+
batches.push(current);
|
|
119
|
+
current = [];
|
|
120
|
+
chars = 0;
|
|
121
|
+
}
|
|
122
|
+
current.push(write);
|
|
123
|
+
chars += size;
|
|
124
|
+
}
|
|
125
|
+
if (current.length > 0)
|
|
126
|
+
batches.push(current);
|
|
127
|
+
return batches;
|
|
128
|
+
}
|
|
129
|
+
export async function importBook(door, book, dir) {
|
|
130
|
+
let layout;
|
|
131
|
+
try {
|
|
132
|
+
layout = await readLayout(dir);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return failAgentDoor(door, TAG, "import_read_failed", error.message);
|
|
136
|
+
}
|
|
137
|
+
const counts = Object.fromEntries(BOOK_COLLECTIONS.map((c) => [c, 0]));
|
|
138
|
+
const batches = batchesOf(layout.writes);
|
|
139
|
+
for (const [index, batch] of batches.entries()) {
|
|
140
|
+
const answer = await booksRequest(door, "POST", `/api/books/${encodeURIComponent(book)}/batch`, "import", { writes: batch });
|
|
141
|
+
if (!answer.ok) {
|
|
142
|
+
const failed = answer.body?.index;
|
|
143
|
+
const at = typeof failed === "number" ? batch[failed] : undefined;
|
|
144
|
+
return failAgentDoor(door, TAG, answer.reason, `${answer.detail} Batch ${index + 1} of ${batches.length} was not written; earlier batches were.`, { written: counts, ...(at ? { record: `${at.collection}/${at.doc_id}` } : {}) });
|
|
145
|
+
}
|
|
146
|
+
for (const write of batch)
|
|
147
|
+
counts[write.collection] = (counts[write.collection] ?? 0) + 1;
|
|
148
|
+
}
|
|
149
|
+
const total = layout.writes.length;
|
|
150
|
+
console.error(`${TAG} imported ${JSON.stringify({ book, total, batches: batches.length, skipped: layout.skipped.length })}`);
|
|
151
|
+
if (door.json)
|
|
152
|
+
return emitAgentDoor(door, { ok: true, book, counts, total, batches: batches.length, skipped: layout.skipped });
|
|
153
|
+
for (const collection of BOOK_COLLECTIONS)
|
|
154
|
+
writeLine(door.io.stdout, `${collection}\t${counts[collection]}`);
|
|
155
|
+
if (layout.skipped.length)
|
|
156
|
+
writeLine(door.io.stdout, `Skipped (not a collection or not .json): ${layout.skipped.join(", ")}`);
|
|
157
|
+
writeLine(door.io.stdout, `Imported ${total} records into ${book} in ${batches.length} batches.`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** `cockpit books` usage text (BLI-23711), in its own module so the help table can print it without loading the command. */
|
|
2
|
+
export const BOOKS_USAGE = [
|
|
3
|
+
"Usage: cockpit books list [--json]",
|
|
4
|
+
" cockpit books create <book> --title \"<title>\" [--json] (founders)",
|
|
5
|
+
" cockpit books members list|add|remove <book> [<email or user id>] [--edit] [--json] (founders)",
|
|
6
|
+
" cockpit books ls <book> <collection> [--after <doc_id>] [--limit <1-500>] [--deleted] [--json]",
|
|
7
|
+
" cockpit books get <book> <collection> <doc_id> [--deleted] [--json]",
|
|
8
|
+
" cockpit books put <book> <collection> <doc_id> [--merge] [--version <n>] [--data '<json>'] [--json] (data on stdin if no --data)",
|
|
9
|
+
" cockpit books delete <book> <collection> <doc_id> [--version <n>] [--json] (soft: the record is kept)",
|
|
10
|
+
" cockpit books batch <book> [--json] ({\"writes\": [...]} on stdin, 1 to 50 writes, all or nothing)",
|
|
11
|
+
" cockpit books export <book> <dir> [--json] (<dir>/<collection>/<doc_id>.json)",
|
|
12
|
+
" cockpit books import <book> <dir> [--json] (the export layout, through the batch door)",
|
|
13
|
+
"Collections: meta, timeline, actions, documents, meetings, conversations, workstreams, sources, syncs.",
|
|
14
|
+
].join("\n");
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { booksRequest, exportBook, importBook } from "./books-files.js";
|
|
3
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
4
|
+
import { BOOKS_USAGE } from "./books-usage.js";
|
|
5
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
6
|
+
export { BOOKS_USAGE };
|
|
7
|
+
const ACTIONS = ["list", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
8
|
+
const BODY_MAX_CHARS = 4_000_000;
|
|
9
|
+
function usage() {
|
|
10
|
+
throw new Error(BOOKS_USAGE);
|
|
11
|
+
}
|
|
12
|
+
function wholeNumber(raw, min, max) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (!/^\d+$/.test(raw))
|
|
16
|
+
usage();
|
|
17
|
+
const n = Number(raw);
|
|
18
|
+
if (n < min || n > max)
|
|
19
|
+
usage();
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
export function parseBooksArgs(args) {
|
|
23
|
+
const values = parseNamedArgs(args, {
|
|
24
|
+
allowedFlags: ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
25
|
+
valueFlags: ["--title", "--after", "--limit", "--version", "--data", "--home", "--dashboard-url"],
|
|
26
|
+
});
|
|
27
|
+
const [action, ...rest] = values.positionals;
|
|
28
|
+
if (!ACTIONS.includes(action ?? ""))
|
|
29
|
+
usage();
|
|
30
|
+
const flags = values.flags;
|
|
31
|
+
const has = (flag) => flags.has(flag) || values.booleans.has(flag);
|
|
32
|
+
const allowOnly = (...allowed) => {
|
|
33
|
+
for (const flag of ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
34
|
+
if (has(flag) && !allowed.includes(flag))
|
|
35
|
+
usage();
|
|
36
|
+
};
|
|
37
|
+
const command = {
|
|
38
|
+
kind: "books",
|
|
39
|
+
action: action,
|
|
40
|
+
json: values.booleans.has("--json"),
|
|
41
|
+
homeDir: optionalNonEmpty(flags.get("--home")),
|
|
42
|
+
dashboardUrl: optionalUrl(flags.get("--dashboard-url")),
|
|
43
|
+
};
|
|
44
|
+
switch (command.action) {
|
|
45
|
+
case "list":
|
|
46
|
+
if (rest.length)
|
|
47
|
+
usage();
|
|
48
|
+
allowOnly();
|
|
49
|
+
return command;
|
|
50
|
+
case "create": {
|
|
51
|
+
const title = optionalNonEmpty(flags.get("--title"));
|
|
52
|
+
if (rest.length !== 1 || !title)
|
|
53
|
+
usage();
|
|
54
|
+
allowOnly("--title");
|
|
55
|
+
return { ...command, book: rest[0], title };
|
|
56
|
+
}
|
|
57
|
+
case "members": {
|
|
58
|
+
const [sub, book, member, ...extra] = rest;
|
|
59
|
+
if (extra.length || !book || (sub !== "list" && sub !== "add" && sub !== "remove"))
|
|
60
|
+
usage();
|
|
61
|
+
if (sub === "list" ? member !== undefined : member === undefined)
|
|
62
|
+
usage();
|
|
63
|
+
allowOnly(...(sub === "add" ? ["--edit"] : []));
|
|
64
|
+
return { ...command, membersAction: sub, book, ...(member === undefined ? {} : { member }), ...(sub === "add" ? { canEdit: values.booleans.has("--edit") } : {}) };
|
|
65
|
+
}
|
|
66
|
+
case "ls": {
|
|
67
|
+
if (rest.length !== 2)
|
|
68
|
+
usage();
|
|
69
|
+
allowOnly("--after", "--limit", "--deleted");
|
|
70
|
+
const limit = wholeNumber(flags.get("--limit"), 1, 500);
|
|
71
|
+
const after = optionalNonEmpty(flags.get("--after"));
|
|
72
|
+
return { ...command, book: rest[0], collection: rest[1], ...(after === undefined ? {} : { after }), ...(limit === undefined ? {} : { limit }), deleted: values.booleans.has("--deleted") };
|
|
73
|
+
}
|
|
74
|
+
case "get":
|
|
75
|
+
if (rest.length !== 3)
|
|
76
|
+
usage();
|
|
77
|
+
allowOnly("--deleted");
|
|
78
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], deleted: values.booleans.has("--deleted") };
|
|
79
|
+
case "put": {
|
|
80
|
+
if (rest.length !== 3)
|
|
81
|
+
usage();
|
|
82
|
+
allowOnly("--merge", "--version", "--data");
|
|
83
|
+
const version = wholeNumber(flags.get("--version"), 0, 2_147_483_647);
|
|
84
|
+
const data = flags.get("--data");
|
|
85
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], merge: values.booleans.has("--merge"), ...(version === undefined ? {} : { version }), ...(data === undefined ? {} : { data }) };
|
|
86
|
+
}
|
|
87
|
+
case "delete": {
|
|
88
|
+
if (rest.length !== 3)
|
|
89
|
+
usage();
|
|
90
|
+
allowOnly("--version");
|
|
91
|
+
const version = wholeNumber(flags.get("--version"), 0, 2_147_483_647);
|
|
92
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], ...(version === undefined ? {} : { version }) };
|
|
93
|
+
}
|
|
94
|
+
case "batch":
|
|
95
|
+
if (rest.length !== 1)
|
|
96
|
+
usage();
|
|
97
|
+
allowOnly();
|
|
98
|
+
return { ...command, book: rest[0] };
|
|
99
|
+
case "export":
|
|
100
|
+
case "import":
|
|
101
|
+
if (rest.length !== 2)
|
|
102
|
+
usage();
|
|
103
|
+
allowOnly();
|
|
104
|
+
return { ...command, book: rest[0], dir: rest[1] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const seg = (value) => encodeURIComponent(value ?? "");
|
|
108
|
+
async function readJsonInput(io, inline, what) {
|
|
109
|
+
let text = inline;
|
|
110
|
+
if (text === undefined) {
|
|
111
|
+
if (isInteractiveStdin(io))
|
|
112
|
+
return { ok: false, detail: `Pipe the ${what} as JSON on stdin (or pass --data '<json>').` };
|
|
113
|
+
text = await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `The JSON on stdin is limited to ${BODY_MAX_CHARS} characters.` });
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return { ok: true, value: JSON.parse(text) };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { ok: false, detail: `The ${what} is not valid JSON.` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const TAG = "[books cli]";
|
|
123
|
+
export async function runBooks(command, io) {
|
|
124
|
+
const door = await openAgentDoor("books", command, io);
|
|
125
|
+
const book = seg(command.book);
|
|
126
|
+
const record = `/api/books/${book}/records/${seg(command.collection)}/${seg(command.docId)}`;
|
|
127
|
+
if (command.action === "export")
|
|
128
|
+
return exportBook(door, command.book, command.dir);
|
|
129
|
+
if (command.action === "import")
|
|
130
|
+
return importBook(door, command.book, command.dir);
|
|
131
|
+
let answer;
|
|
132
|
+
switch (command.action) {
|
|
133
|
+
case "list":
|
|
134
|
+
answer = await booksRequest(door, "GET", "/api/books", "list");
|
|
135
|
+
if (answer.ok && !command.json) {
|
|
136
|
+
const books = answer.body.books;
|
|
137
|
+
if (books.length === 0)
|
|
138
|
+
writeLine(io.stdout, "No books you can open.");
|
|
139
|
+
for (const b of books)
|
|
140
|
+
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "create":
|
|
145
|
+
answer = await booksRequest(door, "POST", "/api/books", "create", { id: command.book, title: command.title });
|
|
146
|
+
break;
|
|
147
|
+
case "members":
|
|
148
|
+
if (command.membersAction === "list") {
|
|
149
|
+
answer = await booksRequest(door, "GET", `/api/books/${book}/members`, "members list");
|
|
150
|
+
if (answer.ok && !command.json) {
|
|
151
|
+
for (const m of answer.body.members) {
|
|
152
|
+
writeLine(io.stdout, `${m.email ?? m.user_id}\t${m.can_edit ? "edit" : "read"}`);
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (command.membersAction === "add") {
|
|
158
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/members`, "members add", { member: command.member, can_edit: command.canEdit === true });
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
answer = await booksRequest(door, "DELETE", `/api/books/${book}/members?member=${seg(command.member)}`, "members remove");
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
case "ls": {
|
|
165
|
+
const query = new URLSearchParams({ collection: command.collection ?? "" });
|
|
166
|
+
if (command.after !== undefined)
|
|
167
|
+
query.set("after", command.after);
|
|
168
|
+
if (command.limit !== undefined)
|
|
169
|
+
query.set("limit", String(command.limit));
|
|
170
|
+
if (command.deleted)
|
|
171
|
+
query.set("include_deleted", "1");
|
|
172
|
+
answer = await booksRequest(door, "GET", `/api/books/${book}/records?${query}`, "ls");
|
|
173
|
+
if (answer.ok && !command.json) {
|
|
174
|
+
const page = answer.body;
|
|
175
|
+
for (const r of page.records)
|
|
176
|
+
writeLine(io.stdout, `${r.doc_id}\tv${r.version}\t${r.updated_at}${r.deleted_at ? "\tdeleted" : ""}`);
|
|
177
|
+
if (page.next_cursor)
|
|
178
|
+
writeLine(io.stdout, `More: --after ${page.next_cursor}`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "get":
|
|
184
|
+
answer = await booksRequest(door, "GET", `${record}${command.deleted ? "?include_deleted=1" : ""}`, "get");
|
|
185
|
+
if (answer.ok && !command.json) {
|
|
186
|
+
writeLine(io.stdout, JSON.stringify(answer.body.record.data, null, 2));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case "put": {
|
|
191
|
+
const data = await readJsonInput(io, command.data, "record data");
|
|
192
|
+
if (!data.ok)
|
|
193
|
+
return failAgentDoor(door, TAG, "invalid_data", data.detail);
|
|
194
|
+
answer = await booksRequest(door, "PUT", record, "put", { data: data.value, merge: command.merge === true, ...(command.version === undefined ? {} : { expected_version: command.version }) });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "delete":
|
|
198
|
+
answer = await booksRequest(door, "DELETE", `${record}${command.version === undefined ? "" : `?expected_version=${command.version}`}`, "delete");
|
|
199
|
+
break;
|
|
200
|
+
case "batch": {
|
|
201
|
+
const body = await readJsonInput(io, undefined, "batch");
|
|
202
|
+
if (!body.ok)
|
|
203
|
+
return failAgentDoor(door, TAG, "invalid_batch", body.detail);
|
|
204
|
+
// A bare array is accepted as the writes list.
|
|
205
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/batch`, "batch", Array.isArray(body.value) ? { writes: body.value } : body.value);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!answer.ok) {
|
|
210
|
+
const extra = answer.body && typeof answer.body === "object" ? Object.fromEntries(Object.entries(answer.body).filter(([key]) => key === "current_version" || key === "index")) : {};
|
|
211
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, extra);
|
|
212
|
+
}
|
|
213
|
+
return emitAgentDoor(door, answer.body);
|
|
214
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cockpit docs share` and `cockpit docs access` — named page
|
|
2
|
+
* `cockpit docs share` and `cockpit docs access` — the named people on a page, from a terminal (BLI-21313, BLI-23558).
|
|
3
3
|
* A sibling of `docs.ts`, like `docs-notion.ts`.
|
|
4
4
|
*
|
|
5
|
-
* `access <id|slug>`
|
|
6
|
-
* `share <id|slug> --add <email
|
|
7
|
-
* `share <id|slug> --remove <email>`
|
|
5
|
+
* `access <id|slug>` → `GET /api/docs/documents/[id]/grants`
|
|
6
|
+
* `share <id|slug> --add <email> [--role viewer|editor]` → `POST /api/docs/documents/[id]/grants`
|
|
7
|
+
* `share <id|slug> --remove <email>` → `DELETE /api/docs/documents/[id]/grants`
|
|
8
8
|
*
|
|
9
9
|
* The door answers with the shared permission explanation, and every refusal keeps its own reason
|
|
10
|
-
* label verbatim (`
|
|
10
|
+
* label verbatim (`not_grant_manager`, `person_not_found`, `grantee_is_author`, ...).
|
|
11
11
|
* A person is an exact email; the server resolves it, so this file never guesses who someone is.
|
|
12
12
|
*/
|
|
13
13
|
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
@@ -31,6 +31,8 @@ function printPermission(door, permission) {
|
|
|
31
31
|
writeLine(door.io.stdout, line);
|
|
32
32
|
for (const editor of permission.editors)
|
|
33
33
|
writeLine(door.io.stdout, ` editor: ${editor.display_name}${editor.email ? ` <${editor.email}>` : ""}`);
|
|
34
|
+
for (const viewer of permission.viewers ?? [])
|
|
35
|
+
writeLine(door.io.stdout, ` viewer: ${viewer.display_name}${viewer.email ? ` <${viewer.email}>` : ""}`);
|
|
34
36
|
}
|
|
35
37
|
export async function accessDocs(command, door) {
|
|
36
38
|
const target = await resolveOrFail(command, door);
|
|
@@ -45,12 +47,12 @@ export async function accessDocs(command, door) {
|
|
|
45
47
|
if (!answer.ok)
|
|
46
48
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
47
49
|
const body = answer.body;
|
|
48
|
-
writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length })}`);
|
|
50
|
+
writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length, viewers: (body.permission.viewers ?? []).length })}`);
|
|
49
51
|
if (door.json)
|
|
50
52
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
51
53
|
printPermission(door, body.permission);
|
|
52
54
|
for (const event of body.events ?? []) {
|
|
53
|
-
writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}`);
|
|
55
|
+
writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}${event.role ? ` (${event.role})` : ""}`);
|
|
54
56
|
}
|
|
55
57
|
return 0;
|
|
56
58
|
}
|
|
@@ -64,18 +66,26 @@ export async function shareDocs(command, door) {
|
|
|
64
66
|
method: adding ? "POST" : "DELETE",
|
|
65
67
|
label: adding ? "docs share add" : "docs share remove",
|
|
66
68
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
67
|
-
body: {
|
|
69
|
+
body: {
|
|
70
|
+
person: adding ? command.addPerson : command.removePerson,
|
|
71
|
+
...(adding && command.shareRole ? { role: command.shareRole } : {}),
|
|
72
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
73
|
+
},
|
|
68
74
|
});
|
|
69
75
|
if (!answer.ok)
|
|
70
76
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
71
77
|
const body = answer.body;
|
|
72
|
-
writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, request_id: body.request_id })}`);
|
|
78
|
+
writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, role: body.role ?? null, request_id: body.request_id })}`);
|
|
73
79
|
if (door.json)
|
|
74
80
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
75
81
|
const person = adding ? command.addPerson : command.removePerson;
|
|
82
|
+
const role = body.role ?? command.shareRole ?? "editor";
|
|
83
|
+
const aRole = `${role === "editor" ? "an" : "a"} ${role}`;
|
|
76
84
|
writeLine(door.io.stdout, body.outcome === "unchanged"
|
|
77
|
-
? `Nothing changed: ${person} ${adding ?
|
|
78
|
-
:
|
|
85
|
+
? `Nothing changed: ${person} ${adding ? `is already ${aRole}` : "was not named on this page"}.`
|
|
86
|
+
: body.reason === "role_changed"
|
|
87
|
+
? `Changed ${person} to ${role} on ${target.id}.`
|
|
88
|
+
: `${adding ? "Added" : "Removed"} ${person} ${adding ? `as ${aRole} of` : "from"} ${target.id}.`);
|
|
79
89
|
printPermission(door, body.permission);
|
|
80
90
|
return 0;
|
|
81
91
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit agreements` argument parsing (docs/architecture/agreements.md).
|
|
3
|
+
*
|
|
4
|
+
* cockpit agreements [list] your own requests to sign: open, signed, cancelled
|
|
5
|
+
* cockpit agreements show <request-id> the text, its boxes, and the record once signed
|
|
6
|
+
* cockpit agreements copy <request-id> [--out <file>] your signed copy as plain text
|
|
7
|
+
* cockpit agreements documents founders: every document, its versions and who was asked
|
|
8
|
+
* cockpit agreements draft founders: save a draft (the DraftInput JSON on stdin)
|
|
9
|
+
* cockpit agreements request --version <id> --person <user-id> [--due <date>] founders: ask a person to sign
|
|
10
|
+
* cockpit agreements cancel <request-id> founders: withdraw a request that is not signed
|
|
11
|
+
*
|
|
12
|
+
* Every verb is one `/api/agreements` door on this machine's device token, the same doors the web page, the MCP twins
|
|
13
|
+
* and JARVIS use. Asking for a signing code, signing and publishing have no verb: they are a person's own act in the
|
|
14
|
+
* browser, and those doors refuse a device token.
|
|
15
|
+
*/
|
|
16
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
17
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const ID_EXAMPLE = "8f14e45f-ceea-467a-9575-1d3a4e7b2c10";
|
|
19
|
+
const ONE_VERB = "agreements takes one verb: list, show, copy, documents, draft, request or cancel. Example: `cockpit agreements` or `cockpit agreements show <request-id>`.";
|
|
20
|
+
/** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
|
|
21
|
+
const FLAGS_BY_ACTION = {
|
|
22
|
+
list: [],
|
|
23
|
+
show: [],
|
|
24
|
+
copy: ["--out"],
|
|
25
|
+
documents: [],
|
|
26
|
+
draft: [],
|
|
27
|
+
request: ["--version", "--person", "--due"],
|
|
28
|
+
cancel: [],
|
|
29
|
+
};
|
|
30
|
+
const OWN_FLAGS = ["--out", "--version", "--person", "--due"];
|
|
31
|
+
const COMMON_FLAGS = ["--home", "--dashboard-url", "--json"];
|
|
32
|
+
const NEEDS_REQUEST_ID = ["show", "copy", "cancel"];
|
|
33
|
+
export function parseAgreementsArgs(args) {
|
|
34
|
+
const values = parseNamedArgs(args, {
|
|
35
|
+
allowedFlags: [...OWN_FLAGS, ...COMMON_FLAGS],
|
|
36
|
+
valueFlags: [...OWN_FLAGS, "--home", "--dashboard-url"],
|
|
37
|
+
});
|
|
38
|
+
const first = values.positionals[0];
|
|
39
|
+
if (first !== undefined && !Object.keys(FLAGS_BY_ACTION).includes(first))
|
|
40
|
+
throw new Error(ONE_VERB);
|
|
41
|
+
const action = (first ?? "list");
|
|
42
|
+
const given = [...values.flags.keys()].filter((flag) => OWN_FLAGS.includes(flag));
|
|
43
|
+
const owned = FLAGS_BY_ACTION[action];
|
|
44
|
+
const stray = given.find((flag) => !owned.includes(flag));
|
|
45
|
+
if (stray)
|
|
46
|
+
throw new Error(`${stray} does not belong to \`cockpit agreements ${action}\`.`);
|
|
47
|
+
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
48
|
+
const needsId = NEEDS_REQUEST_ID.includes(action);
|
|
49
|
+
if (needsId ? rest.length !== 1 : rest.length > 0) {
|
|
50
|
+
throw new Error(needsId ? `\`cockpit agreements ${action}\` takes exactly one request id.` : `\`cockpit agreements ${action}\` takes no positional arguments.`);
|
|
51
|
+
}
|
|
52
|
+
const requestId = needsId ? rest[0] : undefined;
|
|
53
|
+
if (requestId !== undefined && !ID.test(requestId))
|
|
54
|
+
throw new Error(`A request id looks like ${ID_EXAMPLE} (\`cockpit agreements\` lists yours).`);
|
|
55
|
+
const versionId = optionalNonEmpty(values.flags.get("--version"));
|
|
56
|
+
const personId = optionalNonEmpty(values.flags.get("--person"));
|
|
57
|
+
const dueAt = optionalNonEmpty(values.flags.get("--due"));
|
|
58
|
+
if (action === "request") {
|
|
59
|
+
if (!versionId || !personId)
|
|
60
|
+
throw new Error("`cockpit agreements request` needs --version <version-id> and --person <user-id> (`cockpit agreements documents` lists both).");
|
|
61
|
+
if (!ID.test(versionId))
|
|
62
|
+
throw new Error(`--version looks like ${ID_EXAMPLE} (\`cockpit agreements documents\` lists the versions).`);
|
|
63
|
+
if (!ID.test(personId))
|
|
64
|
+
throw new Error(`--person is a user id like ${ID_EXAMPLE} (\`cockpit team members\` lists them).`);
|
|
65
|
+
if (dueAt !== undefined && Number.isNaN(Date.parse(dueAt)))
|
|
66
|
+
throw new Error("--due wants a date, like 2026-11-15.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
kind: "agreements",
|
|
70
|
+
action,
|
|
71
|
+
...(requestId ? { requestId } : {}),
|
|
72
|
+
...(optionalNonEmpty(values.flags.get("--out")) ? { outPath: optionalNonEmpty(values.flags.get("--out")) } : {}),
|
|
73
|
+
...(versionId ? { versionId } : {}),
|
|
74
|
+
...(personId ? { personId } : {}),
|
|
75
|
+
...(dueAt ? { dueAt } : {}),
|
|
76
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
77
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
78
|
+
json: values.booleans.has("--json"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -47,6 +47,7 @@ const DOCS_ALLOWED_FLAGS = [
|
|
|
47
47
|
"--out",
|
|
48
48
|
"--add",
|
|
49
49
|
"--remove",
|
|
50
|
+
"--role",
|
|
50
51
|
"--reason",
|
|
51
52
|
"--cursor",
|
|
52
53
|
"--before",
|
|
@@ -70,6 +71,7 @@ const DOCS_VALUE_FLAGS = [
|
|
|
70
71
|
"--out",
|
|
71
72
|
"--add",
|
|
72
73
|
"--remove",
|
|
74
|
+
"--role",
|
|
73
75
|
"--reason",
|
|
74
76
|
"--cursor",
|
|
75
77
|
"--before",
|
|
@@ -296,24 +298,34 @@ function parseDocsReadFlags(action, values) {
|
|
|
296
298
|
return { query, wholeWord, limit, bodies, section, offset };
|
|
297
299
|
}
|
|
298
300
|
/**
|
|
299
|
-
* BLI-21313: `docs share <doc> --add <email> | --remove <email> [--reason <text>]` names
|
|
300
|
-
* `docs access <doc>` reads who may edit and why. A person
|
|
301
|
+
* BLI-21313, BLI-23558: `docs share <doc> --add <email> [--role viewer|editor] | --remove <email> [--reason <text>]` names
|
|
302
|
+
* or removes a person on a page (a private page too); `docs access <doc>` reads who may read and edit and why. A person
|
|
303
|
+
* is an exact email address, never a fuzzy name. `--role` is for `--add` only; absent means editor (what share did
|
|
304
|
+
* before viewers existed).
|
|
301
305
|
*/
|
|
302
306
|
function parseDocsShareFlags(action, values) {
|
|
303
307
|
const addPerson = optionalNonEmpty(values.flags.get("--add"));
|
|
304
308
|
const removePerson = optionalNonEmpty(values.flags.get("--remove"));
|
|
309
|
+
const role = optionalNonEmpty(values.flags.get("--role"));
|
|
305
310
|
const reason = optionalNonEmpty(values.flags.get("--reason"));
|
|
306
|
-
if ((addPerson || removePerson || reason) && action !== "share") {
|
|
307
|
-
throw new Error("--add, --remove and --reason belong to `cockpit docs share`.");
|
|
311
|
+
if ((addPerson || removePerson || reason || role) && action !== "share") {
|
|
312
|
+
throw new Error("--add, --remove, --role and --reason belong to `cockpit docs share`.");
|
|
308
313
|
}
|
|
314
|
+
if (role !== undefined && role !== "viewer" && role !== "editor")
|
|
315
|
+
throw new Error('docs share --role must be "viewer" or "editor".');
|
|
309
316
|
if (action === "share") {
|
|
310
317
|
if (Boolean(addPerson) === Boolean(removePerson)) {
|
|
311
318
|
throw new Error("docs share needs exactly one of --add <email> or --remove <email>.");
|
|
312
319
|
}
|
|
320
|
+
if (role && !addPerson)
|
|
321
|
+
throw new Error("--role goes with --add; --remove takes the person off whatever role they hold.");
|
|
313
322
|
if (reason && reason.length > 200)
|
|
314
323
|
throw new Error("--reason can be at most 200 characters.");
|
|
315
324
|
}
|
|
316
|
-
return {
|
|
325
|
+
return {
|
|
326
|
+
...(addPerson ? { addPerson } : {}), ...(removePerson ? { removePerson } : {}),
|
|
327
|
+
...(role ? { shareRole: role } : {}), ...(reason ? { reason } : {}),
|
|
328
|
+
};
|
|
317
329
|
}
|
|
318
330
|
/**
|
|
319
331
|
* BLI-21413: `docs trash [--cursor C] [--limit N]`, `docs versions <doc> [--before N] [--limit N] [--version V]`,
|
|
@@ -50,11 +50,13 @@ export { parseWhoArgs } from "./local-args-tower-who.js";
|
|
|
50
50
|
export { parsePeopleArgs } from "./local-args-tower-people.js";
|
|
51
51
|
export { parseApprovalsArgs } from "./local-args-tower-approvals.js";
|
|
52
52
|
export { parseDrillsArgs } from "./local-args-tower-drills.js";
|
|
53
|
+
export { parseAgreementsArgs } from "./local-args-tower-agreements.js";
|
|
53
54
|
export { parseManagerArgs } from "./local-args-tower-manager.js";
|
|
54
55
|
export { parseImageArgs } from "./local-args-tower-image.js";
|
|
55
56
|
export { parseCareersArgs } from './local-args-tower-careers.js';
|
|
56
57
|
export { parseRenameArgs } from './local-args-tower-rename.js';
|
|
57
58
|
export { parseGlossaryArgs } from "./glossary.js";
|
|
59
|
+
export { parseBooksArgs } from "./books.js";
|
|
58
60
|
export { parseSkillsArgs } from "./skills.js";
|
|
59
61
|
export { parseSpendArgs } from "./spend.js";
|
|
60
62
|
export { parsePushArgs } from "./push.js";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* verbatim, no logic change.
|
|
17
17
|
*/
|
|
18
18
|
import { parseGlossaryArgs } from "./glossary.js";
|
|
19
|
+
import { parseBooksArgs } from "./books.js";
|
|
19
20
|
import { parseSkillsArgs } from "./skills.js";
|
|
20
21
|
import { parseSpendArgs } from "./spend.js";
|
|
21
22
|
import { parsePushArgs } from "./push.js";
|
|
@@ -25,7 +26,7 @@ import { parseSlackPrivacyArgs } from "./slack-privacy-args.js";
|
|
|
25
26
|
import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
|
|
26
27
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
27
28
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
28
|
-
import { parseCiArgs, parseDeliveryArgs, parsePrOutputArgs, parseAiSpendArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseAiSkillsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
29
|
+
import { parseCiArgs, parseDeliveryArgs, parsePrOutputArgs, parseAiSpendArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseAiSkillsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseAgreementsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
29
30
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
30
31
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
31
32
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -91,6 +92,7 @@ const COMMAND_PARSERS = new Map([
|
|
|
91
92
|
["clean", (_command, rest) => parseCleanArgs(rest)],
|
|
92
93
|
["sources", (_command, rest) => parseSourcesArgs(rest)],
|
|
93
94
|
["glossary", (_command, rest) => parseGlossaryArgs(rest)],
|
|
95
|
+
["books", (_command, rest) => parseBooksArgs(rest)], // BLI-23711: Client Books
|
|
94
96
|
["skills", (_command, rest) => parseSkillsArgs(rest)], // BLI-23364 D: the Skill Library
|
|
95
97
|
["spend", (_command, rest) => parseSpendArgs(rest)],
|
|
96
98
|
["push", (_command, rest) => parsePushArgs(rest)],
|
|
@@ -118,6 +120,7 @@ const COMMAND_PARSERS = new Map([
|
|
|
118
120
|
["people", (_command, rest) => parsePeopleArgs(rest)],
|
|
119
121
|
["approvals", (_command, rest) => parseApprovalsArgs(rest)],
|
|
120
122
|
["drills", (_command, rest) => parseDrillsArgs(rest)],
|
|
123
|
+
["agreements", (_command, rest) => parseAgreementsArgs(rest)],
|
|
121
124
|
["manager", (_command, rest) => parseManagerArgs(rest)],
|
|
122
125
|
["image", (_command, rest) => parseImageArgs(rest)],
|
|
123
126
|
]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** `cockpit agreements` help (docs/architecture/agreements.md), kept beside the parser in local-args-tower-agreements.ts. */
|
|
2
|
+
export const AGREEMENTS_HELP = [
|
|
3
|
+
"agreements",
|
|
4
|
+
[
|
|
5
|
+
"Usage: cockpit agreements [list] [--dashboard-url <url>] [--json]",
|
|
6
|
+
"",
|
|
7
|
+
" cockpit agreements show <request-id> the exact text, its boxes (required or optional), the status, and the record once signed",
|
|
8
|
+
" cockpit agreements copy <request-id> [--out <file>] your signed copy as plain text: the text, then the record. Signed agreements only",
|
|
9
|
+
" cockpit agreements documents founders: every document, its versions, and who was asked to sign each",
|
|
10
|
+
" cockpit agreements draft founders: save a draft; the POST /api/agreements/documents body as JSON on stdin",
|
|
11
|
+
" cockpit agreements request --version <id> --person <user-id> [--due <date>] founders: ask a person to sign a published version",
|
|
12
|
+
" cockpit agreements cancel <request-id> founders: withdraw a request that is not signed",
|
|
13
|
+
"A founder publishes a document as a fixed version and asks named people to sign it. Your own requests are yours to read; only the founders read anyone else's.",
|
|
14
|
+
"Signing is done in Tower in your browser, never here: show prints the link. Asking for a signing code, signing and publishing a version have no CLI verb or MCP tool,",
|
|
15
|
+
"because an agent holding your token can often read your inbox and could fetch the code and sign, and a published version freezes text people will sign.",
|
|
16
|
+
"draft JSON: {slug, title, label, language?, body, consent_boxes: [{key, wording, required}], needs_countersign?, required_for_access?, needs_handwritten_confirmation?}",
|
|
17
|
+
"creates a document; {document_id, label, ...} adds a draft version to it; {version_id, label, ...} edits a draft that is not published.",
|
|
18
|
+
"--home <dir> and --dashboard-url <url> are for tests and staging.",
|
|
19
|
+
"--json writes one object to stdout (the door's body) and nothing else; a refusal is { ok: false, error, detail } and exits 1",
|
|
20
|
+
"(founders_only, not_found, not_open, not_published, already_asked, slug_taken, label_taken, already_published, invalid_input, not_paired, ...).",
|
|
21
|
+
"Teaching: docs/architecture/agreements.md. The bli-tower MCP server has the twins: agreements_list, agreements_show, agreements_copy, agreements_documents,",
|
|
22
|
+
"agreements_draft, agreements_request, agreements_cancel.",
|
|
23
|
+
],
|
|
24
|
+
];
|
|
@@ -41,7 +41,7 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
41
41
|
"project unknown_project, since_exceeds_retention, stored_read_failed, not_paired, ...",
|
|
42
42
|
"--json writes one object to stdout: { ok, kind, source, window, lines, templates[], routine, notes, text, measure };",
|
|
43
43
|
"a refusal is { ok: false, error, detail } and exits 1. Run list and teaching: docs/runbooks/tower-logs.md.",
|
|
44
|
-
"cockpit logs keys mint|rotate <source> [--json]:
|
|
44
|
+
"cockpit logs keys mint|rotate <source> [--json]: admin or developer only; prints the source ingest key once. Rotation invalidates the old key.",
|
|
45
45
|
"cockpit logs search <project> [--since 30m|6h (default 1h) | --from <iso> [--to <iso>]] [--source gcp|supabase|vercel|railway|deploys|db-host|<label>] [--stream api|postgres|auth]",
|
|
46
46
|
" [--status 500|5xx|500-503] [--method GET] [--route <text>] [--severity error] [--q <text>] [--limit <n>] [--context] [--json]: owner or admin only.",
|
|
47
47
|
" Reads the project's RAW logs live (window at most 24h), scrubs every line, caps the answer (default 100, at most 200) and says \"capped at N of M\".",
|
|
@@ -75,7 +75,7 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
75
75
|
"Retry: cockpit brain retry <id...> [--all-needs-human] [--project <p>] --reason <text> [--json]",
|
|
76
76
|
"Ids are incident UUIDs or BLI issue identifiers. Retry reopens needs_human or not_actionable rows, resets attempts and claims,",
|
|
77
77
|
"and records who, why and when. --all-needs-human selects only needs_human; --project narrows every selection. Other states are skipped.",
|
|
78
|
-
"Only members and
|
|
78
|
+
"Only team members (members, developers and owners) can retry. One comment is posted per incident issue. MCP twin: brain_retry.",
|
|
79
79
|
"",
|
|
80
80
|
"Ladder (BLI-19250, the Tower owner only): cockpit brain ladder show|promote <class> [--anyway] [--note <text>]|demote <class>|predict \"<question>\"|answer \"<reply>\" [--json]",
|
|
81
81
|
"Before the brain or an agent asks you for a call it writes down what it predicts you will answer; your answer scores it. `show` lists",
|
|
@@ -403,7 +403,7 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
403
403
|
"is `scout_board` on the bli-tower server, over the same GET /api/cockpit/scout door.",
|
|
404
404
|
"--days <n> widens or narrows the window (1 to 90; the board defaults to 14).",
|
|
405
405
|
"Reading follows the Scout page audience setting, so whoever can open /scout can run this.",
|
|
406
|
-
"start, dismiss, and undo move one card and are
|
|
406
|
+
"start, dismiss, and undo move one card and are admin or developer actions on every surface;",
|
|
407
407
|
"a refusal is printed as Tower worded it and the board stays readable underneath.",
|
|
408
408
|
"The id can be the first 6 or more characters printed beside each card; an ambiguous",
|
|
409
409
|
"prefix is refused with the candidates rather than acting on the first match.",
|
|
@@ -20,6 +20,7 @@ import { DOCS_INBOX_HELP } from "./local-help-docs-inbox.js";
|
|
|
20
20
|
import { DOCS_RECOVERY_HELP } from "./local-help-docs-recovery.js";
|
|
21
21
|
import { PROJECT_HELP } from "./local-help-project.js";
|
|
22
22
|
import { MILESTONE_HELP } from "./local-help-milestone.js";
|
|
23
|
+
import { AGREEMENTS_HELP } from "./local-help-agreements.js";
|
|
23
24
|
import { INBOX_HELP_LINES } from "./local-help-inbox.js";
|
|
24
25
|
import { WORK_NOTIFY_HELP_LINES } from "./local-help-work-notify.js";
|
|
25
26
|
import { VIEWS_HELP_LINES } from "./local-help-views.js";
|
|
@@ -76,14 +77,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
76
77
|
" --offset <n> --limit <n> — a character range of the body, instead of the whole thing.",
|
|
77
78
|
" --json shape: one ref answers { document }, unchanged from before BLI-10450. Several refs answer { documents: [{ ref, ok, document } | { ref, ok: false, error, detail }] }. The meta line (and --json document.revision) shows the page revision. Agent writes are never queued offline: a write that did not reach Tower fails with its reason, and `docs mutation-status <request-id>` tells whether it landed. Pass the revision you read as expected_revision.",
|
|
78
79
|
"read --parent <id>|root --bodies [--limit <n>] — every document filed directly under that parent, bodies included, in ONE call (GET /api/docs/documents?parent_id=...&bodies=true). --limit here caps the DOCUMENT COUNT (1-50), not characters. --json shape: { documents: [{ id, ..., body_markdown }] }.",
|
|
79
|
-
"create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. --json shape: { document }.",
|
|
80
|
+
"create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. A new page is visible to everyone (org) unless you pass --visibility private; a page made with --parent starts with its parent's visibility and a copy of the parent's named people, and the parent's owner joins it as an editor (share it with `cockpit docs share`). --json shape: { document }.",
|
|
80
81
|
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] [--expected-revision <n>] [--request-id <uuid>] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document, mutation }.",
|
|
81
82
|
" --expected-revision <n> is the page revision you read (`docs read --json` shows document.revision). The save is one atomic step: if someone saved since, nothing is written and you get revision_conflict with the current page (--json: mutation.current). Omit it for an unconditional save. Every save advances the revision.",
|
|
82
83
|
" --request-id <uuid> makes a retry apply once (minted and printed when absent). Reusing it with different input is request_id_reused. If the response is lost, run `cockpit docs mutation-status <uuid>`, then retry the SAME input with the SAME id.",
|
|
83
84
|
"mutation-status <request-uuid> — your own save receipt: committed (with the stored revision), not_committed (with the reason) or unknown_check_again (no receipt yet; it never proves nothing changed). --json shape: { mutation }.",
|
|
84
85
|
" --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
|
|
85
|
-
"access <id|slug> — who may do what on a page and why, in plain sentences (BLI-21313): your role (author, admin, granted_editor, reader), can_read/can_edit/can_manage, the named editors you may see (the owner and administrators
|
|
86
|
-
"share <id|slug> --add <email> | --remove <email> [--reason \"<note>\"] — owner
|
|
86
|
+
"access <id|slug> — who may do what on a page and why, in plain sentences (BLI-21313, BLI-23558): your role (author, admin, granted_editor, granted_viewer, reader), can_read/can_edit/can_manage, the named editors and viewers you may see (on a private page everyone who can open it sees everyone named; on an Everyone page the owner and administrators do, and a named editor sees only their own entry), and the grant history for the owner. An administrator has no special access to a private page: they read it only if the owner named them. The same explanation the page, its versions and its export carry. --json shape: { ok, document_id, permission: { role, can_read, can_edit, can_manage, editors, viewers, editors_complete, reasons }, events }.",
|
|
87
|
+
"share <id|slug> --add <email> [--role viewer|editor] | --remove <email> [--reason \"<note>\"] — the page owner only (an administrator too, on an Everyone page). Names a person on a page, private or Everyone, by exact email, never fuzzy. --role viewer lets them read it; --role editor (the default) also lets them change its title and text, but not visibility, move, delete or who has access. Adding someone already named with a different role changes their role; --remove takes them off whatever role they hold. Refusals: not_grant_manager, person_not_found, grantee_is_author, invalid_role, document_not_found_or_unreadable. Adding someone already named in that role, or removing someone not named, answers outcome unchanged. --json shape: { ok, outcome, reason, role, document_id, grantee_id, request_id, permission }.",
|
|
87
88
|
"comments <id|slug> [--status open|resolved|all] [--cursor <c>] — the page's discussion (BLI-21701): threads newest activity first, 50 per page, each with its comments (author, time, edited), a \"A comment was deleted by <who> on <when>.\" line for a deleted one, and who resolved a resolved thread. Anyone who can read the page can read it. Ends with the open/resolved counts, a `More: --cursor` line, and a coverage note when some comments could not be read. --json shape: { ok, status, can_comment, counts: { open, resolved }, threads: [{ id, resolved, resolved_at, resolved_by_name, comments: [{ id, author_name, body, created_at, edited_at, deleted, marker, can_edit, can_delete }] }], next_cursor, coverage: exhausted|capped|failed_partial, coverage_note, note }.",
|
|
88
89
|
"comment <id|slug> \"<text>\" | - [--request-id <uuid>] — starts a thread (the text is the second argument, or `-` to read stdin; 1-10,000 characters). Add --reply <thread id> to answer a thread, --edit <comment id> to replace your own comment's text (earlier text is not kept), --delete <comment id> (its author, the page owner or an administrator; a soft delete), --resolve <thread id> or --reopen <thread id>. Exactly one action. Only the page owner, administrators and named editors of an organization page can write; everyone else who can read the page is refused with not_commenter and nothing is written. Other labels, verbatim: not_comment_author, comment_not_found, thread_not_found, invalid_body, document_not_found_or_unreadable. The same --request-id twice gives one comment (outcome unchanged); resolving a resolved thread answers unchanged. --json shape: { ok, outcome, reason, document_id, thread_id, comment_id, request_id }.",
|
|
89
90
|
"delete <id|slug> [<id|slug>...] — soft-deletes one or more documents (BLI-10555): the browser's own delete, sets deleted_at, never a real SQL DELETE. Recoverable: `cockpit docs trash` lists what you may restore and `cockpit docs restore <id>` brings it back (BLI-21413). Deleting a parent does NOT delete its children; they keep their own parent_id and stay exactly where they were. Each ref is one exact lookup, then one DELETE each — one bad ref never loses the rest. --json shape: one ref answers { ok, document_id }; several answer { documents: [{ ref, ok, document_id } | { ref, ok: false, error, detail }] }.",
|
|
@@ -220,6 +221,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
220
221
|
],
|
|
221
222
|
PROJECT_HELP,
|
|
222
223
|
MILESTONE_HELP,
|
|
224
|
+
AGREEMENTS_HELP,
|
|
223
225
|
[
|
|
224
226
|
"speed",
|
|
225
227
|
[
|
|
@@ -241,7 +243,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
241
243
|
"--seen reads what REAL visits saw instead: per page the visit count, p50 and p95 first-useful milliseconds (the moment",
|
|
242
244
|
"real content showed, from the last-known screen or the fresh one), the share painted from the snapshot, and median HTML",
|
|
243
245
|
"and script transfer. The browser sends one metadata-only beacon per page view; nothing is measured from this machine.",
|
|
244
|
-
"--since <n>m|h|d is the window (default 24h, at most 30d). --everyone reads
|
|
246
|
+
"--since <n>m|h|d is the window (default 24h, at most 30d). --everyone reads other people's visits (only Vee, Jeetesh and Ed; a founder's visits stay with that founder; anyone else is refused everyone_needs_super_admin and still gets their own).",
|
|
245
247
|
"A member sees their own visits. --page limits the table to those pages. The same read is the speed_seen MCP tool.",
|
|
246
248
|
],
|
|
247
249
|
],
|
|
@@ -256,7 +258,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
256
258
|
"--by-client (ticket 19258) shows which app each person worked in (Claude Code CLI, desktop or IDE; Codex CLI, desktop, IDE or exec): the share of sessions and of session time, and the median hours from session start to the pull request opened and merged and the active minutes per merged PR, each with its N, compared only within one person and only with 5 or more sessions on each side. --by-topic adds topic rows, including (unlabelled). Choose only one grouping. --by-repo adds project rows under each person (top 10; --all shows every repo). --person me uses your signed-in email.",
|
|
257
259
|
"Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
|
|
258
260
|
"and the input / cache split when the row carries it), and an API list-price equivalent that is",
|
|
259
|
-
"labelled as such and is never actual spend.
|
|
261
|
+
"labelled as such and is never actual spend. You see your own numbers; Vee, Jeetesh and Ed also see everyone else's, except a founder's, which only that founder sees (BLI-23585, BLI-23624). When rows were left out the answer says how many people are shown and how many were withheld. `usage sessions` follows the same rule.",
|
|
260
262
|
"--since takes <n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp; --until an ISO timestamp (default now).",
|
|
261
263
|
"--detail adds Output, Input, Cache read and Cache creation. Counts use K/M/B; dollars are rounded.",
|
|
262
264
|
"PRs opened, PRs merged and Commits come from the pull requests Tower mirrors from GitHub, matched to people by GitHub login. n/a means Tower cannot match that person to a login: unknown, not zero. The footer says how many repositories the mirror holds, the range of its pull requests and what matched nobody; --json lists the repositories.",
|
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
* something breaks, so it is a user-visible contract like any other.
|
|
18
18
|
*/
|
|
19
19
|
import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
|
|
20
|
+
import { BOOKS_USAGE } from "./books-usage.js";
|
|
20
21
|
import { localCommandHelp, SURFACE_ALIASES } from "./local-help.js";
|
|
21
22
|
import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
|
|
22
23
|
import { TOWER_OPS_COMMAND_HELP } from "./local-help-commands-tower-ops.js";
|
|
23
24
|
/** One manual per collector command; the Tower nouns are spread in from their own file. */
|
|
24
25
|
const COMMAND_HELP = new Map([
|
|
26
|
+
["books", [BOOKS_USAGE, "Client Books: each engagement's record (timeline, actions, documents, meetings, ...). A member of a book reads it; a member with edit access, or a founder, writes it; only founders create books and manage members. put without --merge replaces the record's data; --merge changes only the fields given. --version <n> makes a write conditional: refused as version_conflict when someone changed the record since you read version n (0 = must not exist yet). delete is soft. export writes <dir>/<collection>/<doc_id>.json; import reads that layout and writes 50 records per batch. MCP twins: books_list, books_create, books_members, books_read, books_write, books_batch. Docs: docs/architecture/client-books.md."]],
|
|
25
27
|
["glossary", ["Usage: cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--json]", "Quote phrases. Removing a form prevents future corrections of it."]],
|
|
26
|
-
["skills", ["Usage: cockpit skills library [--weeks <1-52>] [--json] | cockpit skills refresh [--weeks <1-104>] [--json]", "The Skill Library: every agent skill and command our GitHub repos define (name, description, repo, path, versions) with weekly use by person, from the sessions Tower already collects. Uses that match no scanned skill are listed as unmatched, never dropped. The first line is the coverage: repos scanned, skills, invocations matched of total.
|
|
28
|
+
["skills", ["Usage: cockpit skills library [--weeks <1-52>] [--json] | cockpit skills refresh [--weeks <1-104>] [--json]", "The Skill Library: every agent skill and command our GitHub repos define (name, description, repo, path, versions) with weekly use by person, from the sessions Tower already collects. Uses that match no scanned skill are listed as unmatched, never dropped. The first line is the coverage: repos scanned, skills, invocations matched of total. You see your own usage; Vee, Jeetesh and Ed see everyone's except a founder's. refresh scans the repos and recomputes the weekly totals now (super_admin only; a stale library also refreshes itself when read). MCP twins: skills_library, skills_refresh. Web: TEAM > Skills."]],
|
|
27
29
|
["push", ["Usage: cockpit push status | test [--dashboard-url <url>] [--json]", "Phone notifications. status lists your saved devices and whether the server has its push keys; test sends a test push to every saved device. Turn a phone on from Settings on the phone."]],
|
|
28
30
|
["spend", ["Usage: cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]", "Spend by memory operation (write, recall, dream, update_forget, backfill, label, exam, predictor, people, who, intentions, salience, chat, other), ranked top first with list and real dollars, share, trend against the previous window, and how much of the real OpenAI bill the ledger explains. What it does not explain is shown as untagged. A super_admin reads it."]],
|
|
29
31
|
["roots", [
|
|
@@ -212,7 +214,7 @@ const COMMAND_HELP = new Map([
|
|
|
212
214
|
" cockpit team invite <email> --role <role> [--team-id <uuid>]",
|
|
213
215
|
" Admin only. Defaults to your own team; Tower emails them a sign-in link.",
|
|
214
216
|
" cockpit team role <userId> --role <role> [--yes]",
|
|
215
|
-
" Admin only. Asks first unless --yes. You cannot change your own role.",
|
|
217
|
+
" Admin only. Asks first unless --yes. You cannot change your own role. Making someone an admin or developer works only from a signed-in browser.",
|
|
216
218
|
" cockpit team grant <person-uuid> careers [see|act] [--json]",
|
|
217
219
|
" cockpit team revoke <person-uuid> careers [see|act] [--json]",
|
|
218
220
|
" Super admins manage separate See and Act grants. Default level: see.",
|
|
@@ -223,7 +225,7 @@ const COMMAND_HELP = new Map([
|
|
|
223
225
|
" (revoked reason, or when it was approved, or that it's still waiting).",
|
|
224
226
|
" Admin only.",
|
|
225
227
|
" cockpit team device scope <id|name> notetaker_bot|none [--json]",
|
|
226
|
-
"
|
|
228
|
+
" Admin or developer only. Gives the meeting bot's device the scope Tower's claim door requires,",
|
|
227
229
|
" or takes it away. Without it the bot is refused every meeting (bot_scope_required).",
|
|
228
230
|
" cockpit team device mark-service <id|name> --reason <label> [--json]",
|
|
229
231
|
" cockpit team device unmark-service <id|name> [--json]",
|
|
@@ -76,6 +76,7 @@ export const rootCommandNames = new Set([
|
|
|
76
76
|
"clean",
|
|
77
77
|
"sources",
|
|
78
78
|
"glossary",
|
|
79
|
+
"books",
|
|
79
80
|
"skills",
|
|
80
81
|
"spend",
|
|
81
82
|
"push",
|
|
@@ -103,6 +104,7 @@ export const rootCommandNames = new Set([
|
|
|
103
104
|
"people",
|
|
104
105
|
"approvals",
|
|
105
106
|
"drills",
|
|
107
|
+
"agreements",
|
|
106
108
|
"manager",
|
|
107
109
|
"image",
|
|
108
110
|
...SURFACE_ALIASES.keys(),
|
|
@@ -166,6 +168,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
166
168
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
167
169
|
" cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
|
|
168
170
|
" cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
|
|
171
|
+
" cockpit books list|create|members|ls|get|put|delete|batch|export|import ... [--dashboard-url <url>] [--json]",
|
|
169
172
|
" cockpit skills library [--weeks <1-52>] | refresh [--weeks <1-104>] [--dashboard-url <url>] [--json]",
|
|
170
173
|
" cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]",
|
|
171
174
|
" cockpit push status|test [--dashboard-url <url>] [--json]",
|
|
@@ -183,6 +186,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
183
186
|
" cockpit ai-skills [--who <me|team|email|user-id>] [--weeks <n>] [--week <YYYY-MM-DD>] [--dashboard-url <url>] [--json]",
|
|
184
187
|
" cockpit approvals [list|show <id>|create|approve <id> [--edit]|edit <id> [--approve]|reject <id>|rules [--active true|false]|rules create <card-id> [--any-audience] [--label <text>]|rules revoke <rule-id> [--reason <text>]] [--status <s>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
|
|
185
188
|
" cockpit drills [list|start|answer --confidence <1-5> [--text <words>] [--attempt <id>]|theme|theme set <theme> [--play <t>] [--watch <t>] [--train <t>]|review] [--dashboard-url <url>] [--json]",
|
|
189
|
+
" cockpit agreements [list|show <request-id>|copy <request-id> [--out <file>]|documents|draft|request --version <id> --person <user-id> [--due <date>]|cancel <request-id>] [--dashboard-url <url>] [--json]",
|
|
186
190
|
" cockpit manager <clients|client <client>|run <run-id>|list <client>|queue <client>|show <id>|add <client> --title <t>|edit <id> --version <v>|done|reopen|link|approve|dismiss|proposal|archive|unarchive> [--timeline] [--dashboard-url <url>] [--json]",
|
|
187
191
|
" cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
|
|
188
192
|
" cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -143,6 +143,7 @@ async function runTowerCommand(command, io) {
|
|
|
143
143
|
case "correct": return await (await import("./correct.js")).runCorrect(command, io);
|
|
144
144
|
case "notes": return await (await import("./notes.js")).runNotes(command, io);
|
|
145
145
|
case "glossary": return await (await import("./glossary.js")).runGlossary(command, io);
|
|
146
|
+
case "books": return await (await import("./books.js")).runBooks(command, io);
|
|
146
147
|
case "skills": return await (await import("./skills.js")).runSkills(command, io);
|
|
147
148
|
case "sessions-forget": return await (await import("./sessions-forget.js")).runSessionsForget(command, io);
|
|
148
149
|
case "spend": return await (await import("./spend.js")).runSpend(command, io);
|
|
@@ -169,6 +170,7 @@ async function runTowerCommand(command, io) {
|
|
|
169
170
|
case "people": return await (await import("./people.js")).runPeople(command, io);
|
|
170
171
|
case "approvals": return await (await import("./approvals.js")).runApprovals(command, io);
|
|
171
172
|
case "drills": return await (await import("./approvals.js")).runDrills(command, io);
|
|
173
|
+
case "agreements": return await (await import("./agreements.js")).runAgreements(command, io);
|
|
172
174
|
case "manager": return await (await import("./manager.js")).runManager(command, io);
|
|
173
175
|
case "image": return await (await import("./image.js")).runImage(command, io);
|
|
174
176
|
}
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.288");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -132,7 +132,7 @@ async function setSwitch(command, tower, io) {
|
|
|
132
132
|
*
|
|
133
133
|
* Reading it is open to anybody signed in — the person whose laptop is about to
|
|
134
134
|
* update is entitled to know what it is updating to — and raising it is
|
|
135
|
-
*
|
|
135
|
+
* an owner or developer (BLI-22248), decided on the server. `scripts/publish-public-packages.mjs`
|
|
136
136
|
* spends the SET verb after a `--tag latest` publish, which is the whole reason
|
|
137
137
|
* this exists: a fix on npm that no machine installs is not shipped.
|
|
138
138
|
*/
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* median HTML and script transfer where the browser knew them.
|
|
12
12
|
*
|
|
13
13
|
* The door (`GET /api/speed/seen`) does the counting in the database, on the
|
|
14
|
-
* caller's own row policies: a person sees their own visits; `--everyone`
|
|
15
|
-
*
|
|
14
|
+
* caller's own row policies: a person sees their own visits; `--everyone` is for the
|
|
15
|
+
* person-data allow-list (Vee, Jeetesh, Ed) and is refused for anyone else. stdout is `--json`'s alone;
|
|
16
16
|
* every log line goes to stderr, metadata only (counts, a window, a reason).
|
|
17
17
|
*/
|
|
18
18
|
import { writeLine } from "./cli-io.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.288",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.89",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.175",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.95",
|
|
33
33
|
"unpdf": "1.8.1"
|
|
34
34
|
},
|