@bli-cockpit/cli 0.2.55 → 0.2.57
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/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +5 -1
- package/dist/commands/local-help.js +36 -0
- package/dist/commands/local.js +6 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install.js +42 -102
- package/dist/commands/msg.js +188 -0
- package/dist/commands/ops-render.js +6 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +3 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request, and a refusal that keeps the DOOR'S own words (BLI-3706).
|
|
3
|
+
*
|
|
4
|
+
* `docs`/`msg` answer a refusal with `{ code: "BLI-E<status>", reason, message }`
|
|
5
|
+
* (`lib/docs/api-doors.ts` / `lib/msg/api-doors.ts`), a different shape from
|
|
6
|
+
* the meeting-notes doors' `{ headline, lines, reason }` that `notes.ts`'s own
|
|
7
|
+
* `ask()` reads. This is that same discipline for the newer shape: the
|
|
8
|
+
* `reason` label a door chose travels to the terminal UNCHANGED, and `message`
|
|
9
|
+
* is the sentence a person reads — never a second wording invented here.
|
|
10
|
+
*
|
|
11
|
+
* `cockpit docs` and `cockpit msg` both call this rather than keeping two
|
|
12
|
+
* copies, because the shape is identical on both surfaces (BLI-3654 Wave 1a
|
|
13
|
+
* and Wave 3a share `resolveCaller` + a `{code,reason,message}` refusal).
|
|
14
|
+
*/
|
|
15
|
+
import { writeLine } from "./cli-io.js";
|
|
16
|
+
import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
|
|
17
|
+
import { readResponseJson } from "../upload-http.js";
|
|
18
|
+
/** Loads the paired session and builds the door every verb in one command run shares. */
|
|
19
|
+
export async function openAgentDoor(commandName, command, io) {
|
|
20
|
+
const session = await loadPairedSession(commandName, command.homeDir);
|
|
21
|
+
return {
|
|
22
|
+
dashboardUrl: command.dashboardUrl ?? session.dashboard_url,
|
|
23
|
+
deviceToken: session.device_token,
|
|
24
|
+
io,
|
|
25
|
+
json: command.json,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function askAgentDoor(door, options) {
|
|
29
|
+
const result = await towerRequest({
|
|
30
|
+
dashboardUrl: door.dashboardUrl,
|
|
31
|
+
path: options.path,
|
|
32
|
+
deviceToken: door.deviceToken,
|
|
33
|
+
fetch: door.io.fetch,
|
|
34
|
+
method: options.method,
|
|
35
|
+
label: options.label,
|
|
36
|
+
timeoutMs: options.timeoutMs,
|
|
37
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
38
|
+
log: (line) => writeLine(door.io.stderr, line),
|
|
39
|
+
});
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
const failure = result;
|
|
42
|
+
return { ok: false, reason: failure.reason, detail: towerFailureDetail(failure.reason, failure.detail) };
|
|
43
|
+
}
|
|
44
|
+
const body = await readResponseJson(result.response);
|
|
45
|
+
if (!result.response.ok) {
|
|
46
|
+
const status = result.response.status;
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
httpStatus: status,
|
|
50
|
+
reason: doorReason(body) ?? `http_${status}`,
|
|
51
|
+
detail: doorMessage(body) ?? `Tower answered ${status} and said nothing about why.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, httpStatus: result.response.status, body };
|
|
55
|
+
}
|
|
56
|
+
/** The door's own `reason` label (`document_not_found_or_unreadable`, `needs_rls_client`, …), verbatim. */
|
|
57
|
+
function doorReason(body) {
|
|
58
|
+
if (!body || typeof body !== "object")
|
|
59
|
+
return null;
|
|
60
|
+
const record = body;
|
|
61
|
+
const value = record["reason"] ?? record["error"];
|
|
62
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
63
|
+
}
|
|
64
|
+
function doorMessage(body) {
|
|
65
|
+
if (!body || typeof body !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const record = body;
|
|
68
|
+
const value = record["message"];
|
|
69
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
70
|
+
}
|
|
71
|
+
/** One machine-readable object on stdout, and nothing else on it. */
|
|
72
|
+
export function emitAgentDoor(door, body, exitCode = 0) {
|
|
73
|
+
writeLine(door.io.stdout, JSON.stringify(body));
|
|
74
|
+
return exitCode;
|
|
75
|
+
}
|
|
76
|
+
export function failAgentDoor(door, tag, reason, detail) {
|
|
77
|
+
writeLine(door.io.stderr, `${tag} refused ${JSON.stringify({ reason })}`);
|
|
78
|
+
if (door.json) {
|
|
79
|
+
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
writeLine(door.io.stderr, detail);
|
|
83
|
+
}
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit docs` — the document library, typed (BLI-3706).
|
|
3
|
+
*
|
|
4
|
+
* Five verbs over `/api/docs/**`, the same doors the `/workbook`… no, the
|
|
5
|
+
* SEPARATE `/api/docs/**` surface (BLI-3654 Wave 3a) that the browser's own
|
|
6
|
+
* document editor reads and writes. Every `/api/docs/**` route already opted
|
|
7
|
+
* into the collector device token via `resolveCaller({ allowDeviceToken: true
|
|
8
|
+
* })` — this command needed no server-side door change, only a terminal in
|
|
9
|
+
* front of the existing one.
|
|
10
|
+
*
|
|
11
|
+
* `read <id|slug>` and `update <id>` both accept a full document id OR its
|
|
12
|
+
* slug. There is no `GET` by slug on the server (`getDocument` reads by id
|
|
13
|
+
* only), so a slug is resolved locally against the flat list `GET
|
|
14
|
+
* /api/docs/documents` already returns — the same read `docs list` prints,
|
|
15
|
+
* never a second query the server does not already answer.
|
|
16
|
+
*
|
|
17
|
+
* A refusal keeps the door's own `reason` label verbatim (`agent-door.ts`):
|
|
18
|
+
* `needs_rls_client`, `document_not_found_or_unreadable`,
|
|
19
|
+
* `document_not_writable`, `title_too_long`, `content_too_long`,
|
|
20
|
+
* `circular_parent`, and so on — never rephrased here.
|
|
21
|
+
*/
|
|
22
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
23
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
24
|
+
import { readNoteFile } from "./notes-file.js";
|
|
25
|
+
const TAG = "[docs cli]";
|
|
26
|
+
const READ_DEADLINE_MS = 30_000;
|
|
27
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
28
|
+
const BODY_MAX_CHARS = 2_000_000;
|
|
29
|
+
export async function runDocs(command, io) {
|
|
30
|
+
const door = await openAgentDoor("docs", command, io);
|
|
31
|
+
switch (command.action) {
|
|
32
|
+
case "list":
|
|
33
|
+
return listDocs(door);
|
|
34
|
+
case "tree":
|
|
35
|
+
return treeDocs(door);
|
|
36
|
+
case "read":
|
|
37
|
+
return readDoc(command, door);
|
|
38
|
+
case "create":
|
|
39
|
+
return createDoc(command, door);
|
|
40
|
+
case "update":
|
|
41
|
+
return updateDoc(command, door);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function listDocs(door) {
|
|
45
|
+
const answer = await askAgentDoor(door, {
|
|
46
|
+
path: "/api/docs/documents",
|
|
47
|
+
method: "GET",
|
|
48
|
+
label: "docs list",
|
|
49
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
50
|
+
});
|
|
51
|
+
if (!answer.ok)
|
|
52
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
53
|
+
const documents = (answer.body.documents ?? []);
|
|
54
|
+
if (door.json)
|
|
55
|
+
return emitAgentDoor(door, { ok: true, documents });
|
|
56
|
+
if (documents.length === 0) {
|
|
57
|
+
writeLine(door.io.stdout, "No documents.");
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
for (const doc of documents) {
|
|
61
|
+
writeLine(door.io.stdout, `${doc.id} ${doc.visibility.padEnd(7)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
62
|
+
}
|
|
63
|
+
writeLine(door.io.stdout, "");
|
|
64
|
+
writeLine(door.io.stdout, `${documents.length} document(s).`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
function renderTree(nodes, io, depth) {
|
|
68
|
+
for (const node of nodes) {
|
|
69
|
+
writeLine(io.stdout, `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`);
|
|
70
|
+
if (node.children.length > 0)
|
|
71
|
+
renderTree(node.children, io, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function treeDocs(door) {
|
|
75
|
+
const answer = await askAgentDoor(door, {
|
|
76
|
+
path: "/api/docs/tree",
|
|
77
|
+
method: "GET",
|
|
78
|
+
label: "docs tree",
|
|
79
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
80
|
+
});
|
|
81
|
+
if (!answer.ok)
|
|
82
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
83
|
+
const tree = (answer.body.tree ?? []);
|
|
84
|
+
if (door.json)
|
|
85
|
+
return emitAgentDoor(door, { ok: true, tree });
|
|
86
|
+
if (tree.length === 0) {
|
|
87
|
+
writeLine(door.io.stdout, "No documents.");
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
renderTree(tree, door.io, 0);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
/** Finds a document id by exact id or exact slug against the flat list. Never fuzzy. */
|
|
94
|
+
async function resolveDocId(door, ref) {
|
|
95
|
+
const answer = await askAgentDoor(door, {
|
|
96
|
+
path: "/api/docs/documents",
|
|
97
|
+
method: "GET",
|
|
98
|
+
label: "docs resolve",
|
|
99
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
100
|
+
});
|
|
101
|
+
if (!answer.ok)
|
|
102
|
+
return { status: "list_failed", reason: answer.reason, detail: answer.detail };
|
|
103
|
+
const documents = (answer.body.documents ?? []);
|
|
104
|
+
const byId = documents.find((doc) => doc.id === ref);
|
|
105
|
+
if (byId)
|
|
106
|
+
return { status: "ok", id: byId.id };
|
|
107
|
+
const bySlug = documents.find((doc) => doc.slug === ref);
|
|
108
|
+
if (bySlug)
|
|
109
|
+
return { status: "ok", id: bySlug.id };
|
|
110
|
+
return { status: "not_found" };
|
|
111
|
+
}
|
|
112
|
+
async function readDoc(command, door) {
|
|
113
|
+
const ref = command.docRef ?? "";
|
|
114
|
+
const resolved = await resolveDocId(door, ref);
|
|
115
|
+
if (resolved.status === "list_failed")
|
|
116
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
117
|
+
if (resolved.status === "not_found") {
|
|
118
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${ref}`);
|
|
119
|
+
}
|
|
120
|
+
const answer = await askAgentDoor(door, {
|
|
121
|
+
path: `/api/docs/documents/${encodeURIComponent(resolved.id)}`,
|
|
122
|
+
method: "GET",
|
|
123
|
+
label: "docs read",
|
|
124
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
125
|
+
});
|
|
126
|
+
if (!answer.ok)
|
|
127
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
128
|
+
const document = answer.body.document ?? {};
|
|
129
|
+
if (door.json)
|
|
130
|
+
return emitAgentDoor(door, { ok: true, document });
|
|
131
|
+
writeLine(door.io.stdout, String(document["title"] ?? ""));
|
|
132
|
+
writeLine(door.io.stdout, `${document["slug"] ?? "(no slug)"} · ${document["visibility"] ?? ""} · ${document["source"] ?? ""}`);
|
|
133
|
+
writeLine(door.io.stdout, "");
|
|
134
|
+
writeLine(door.io.stdout, String(document["body_markdown"] ?? ""));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
/** Body on stdin, never argv (BLI-3706) — `--file` is the safest way on Windows, same as `cockpit notes paste`. */
|
|
138
|
+
async function readBody(command, io) {
|
|
139
|
+
if (command.filePath) {
|
|
140
|
+
const read = await readNoteFile(command.filePath);
|
|
141
|
+
if (!read.ok)
|
|
142
|
+
return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
|
|
143
|
+
return { ok: true, text: read.bytes.toString("utf8") };
|
|
144
|
+
}
|
|
145
|
+
if (isInteractiveStdin(io))
|
|
146
|
+
return { ok: true, text: "" };
|
|
147
|
+
try {
|
|
148
|
+
const text = await readPipedText(io.stdin, {
|
|
149
|
+
maxChars: BODY_MAX_CHARS,
|
|
150
|
+
overflowMessage: `A document body is limited to ${BODY_MAX_CHARS} characters.`,
|
|
151
|
+
});
|
|
152
|
+
return { ok: true, text };
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function createDoc(command, door) {
|
|
159
|
+
if (!command.title)
|
|
160
|
+
return failAgentDoor(door, TAG, "invalid_body", "docs create needs --title.");
|
|
161
|
+
const body = await readBody(command, door.io);
|
|
162
|
+
if (!body.ok)
|
|
163
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
164
|
+
const answer = await askAgentDoor(door, {
|
|
165
|
+
path: "/api/docs/documents",
|
|
166
|
+
method: "POST",
|
|
167
|
+
label: "docs create",
|
|
168
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
169
|
+
body: {
|
|
170
|
+
title: command.title,
|
|
171
|
+
body_markdown: body.text,
|
|
172
|
+
...(command.parentId ? { parent_id: command.parentId } : {}),
|
|
173
|
+
...(command.visibility ? { visibility: command.visibility } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
if (!answer.ok)
|
|
177
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
178
|
+
const document = answer.body.document ?? {};
|
|
179
|
+
writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ document_id: document["id"] ?? null, byte_size: Buffer.byteLength(body.text, "utf8") })}`);
|
|
180
|
+
if (door.json)
|
|
181
|
+
return emitAgentDoor(door, { ok: true, document });
|
|
182
|
+
writeLine(door.io.stdout, `Created ${String(document["title"] ?? "")} (${String(document["id"] ?? "")}).`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
async function updateDoc(command, door) {
|
|
186
|
+
const ref = command.docRef ?? "";
|
|
187
|
+
const resolved = await resolveDocId(door, ref);
|
|
188
|
+
if (resolved.status === "list_failed")
|
|
189
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
190
|
+
if (resolved.status === "not_found") {
|
|
191
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot write to it: ${ref}`);
|
|
192
|
+
}
|
|
193
|
+
let bodyMarkdown;
|
|
194
|
+
if (command.bodyStdin || command.filePath) {
|
|
195
|
+
const body = await readBody(command, door.io);
|
|
196
|
+
if (!body.ok)
|
|
197
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
198
|
+
bodyMarkdown = body.text;
|
|
199
|
+
}
|
|
200
|
+
if (bodyMarkdown === undefined
|
|
201
|
+
&& command.title === undefined
|
|
202
|
+
&& command.visibility === undefined
|
|
203
|
+
&& command.parentId === undefined
|
|
204
|
+
&& !command.clearParent) {
|
|
205
|
+
return failAgentDoor(door, TAG, "invalid_body", "docs update needs at least one of --title, --visibility, --parent/--clear-parent, or a body on --body-stdin/--file.");
|
|
206
|
+
}
|
|
207
|
+
const answer = await askAgentDoor(door, {
|
|
208
|
+
path: `/api/docs/documents/${encodeURIComponent(resolved.id)}`,
|
|
209
|
+
method: "PATCH",
|
|
210
|
+
label: "docs update",
|
|
211
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
212
|
+
body: {
|
|
213
|
+
...(command.title !== undefined ? { title: command.title } : {}),
|
|
214
|
+
...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
|
|
215
|
+
...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
|
|
216
|
+
...(command.clearParent ? { parent_id: null } : command.parentId ? { parent_id: command.parentId } : {}),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
if (!answer.ok)
|
|
220
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
221
|
+
const document = answer.body.document ?? {};
|
|
222
|
+
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
|
|
223
|
+
if (door.json)
|
|
224
|
+
return emitAgentDoor(door, { ok: true, document });
|
|
225
|
+
writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit docs` and `cockpit msg` argument parsing (BLI-3706). Split into its
|
|
3
|
+
* own sibling of `local-args-tower.ts` rather than folded into
|
|
4
|
+
* `local-args-tower-pages.ts`: docs is a page-reading surface like notes and
|
|
5
|
+
* workbook, but msg is not — it is a live two-way channel, closer to
|
|
6
|
+
* `cockpit jarvis` in shape. Keeping the pair together names the ticket that
|
|
7
|
+
* added both without stretching either existing family's own doc comment.
|
|
8
|
+
*/
|
|
9
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
10
|
+
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update"]);
|
|
11
|
+
const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["read", "update"]);
|
|
12
|
+
export function parseDocsArgs(args) {
|
|
13
|
+
const values = parseNamedArgs(args, {
|
|
14
|
+
allowedFlags: [
|
|
15
|
+
"--home",
|
|
16
|
+
"--dashboard-url",
|
|
17
|
+
"--title",
|
|
18
|
+
"--parent",
|
|
19
|
+
"--clear-parent",
|
|
20
|
+
"--visibility",
|
|
21
|
+
"--file",
|
|
22
|
+
"--body-stdin",
|
|
23
|
+
"--json",
|
|
24
|
+
],
|
|
25
|
+
valueFlags: ["--home", "--dashboard-url", "--title", "--parent", "--visibility", "--file"],
|
|
26
|
+
});
|
|
27
|
+
const first = values.positionals[0];
|
|
28
|
+
const action = (first === undefined ? "list" : first);
|
|
29
|
+
if (!DOCS_ACTIONS.has(action)) {
|
|
30
|
+
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, or update.`);
|
|
31
|
+
}
|
|
32
|
+
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
33
|
+
let docRef;
|
|
34
|
+
if (DOCS_ACTIONS_NEEDING_A_DOC.has(action)) {
|
|
35
|
+
docRef = optionalNonEmpty(rest[0]);
|
|
36
|
+
if (!docRef)
|
|
37
|
+
throw new Error(`docs ${action} needs a document id or slug.`);
|
|
38
|
+
if (rest.length > 1)
|
|
39
|
+
throw new Error(`docs ${action} takes one document reference, not ${rest.length}.`);
|
|
40
|
+
}
|
|
41
|
+
else if (rest.length > 0) {
|
|
42
|
+
throw new Error(`docs ${action} does not take "${rest[0]}".`);
|
|
43
|
+
}
|
|
44
|
+
const visibility = optionalNonEmpty(values.flags.get("--visibility"));
|
|
45
|
+
if (visibility && visibility !== "org" && visibility !== "private") {
|
|
46
|
+
throw new Error('docs --visibility must be "org" or "private".');
|
|
47
|
+
}
|
|
48
|
+
const clearParent = values.booleans.has("--clear-parent");
|
|
49
|
+
const parentId = optionalNonEmpty(values.flags.get("--parent"));
|
|
50
|
+
if (clearParent && parentId) {
|
|
51
|
+
throw new Error("docs update accepts either --parent or --clear-parent, not both.");
|
|
52
|
+
}
|
|
53
|
+
if (clearParent && action !== "update") {
|
|
54
|
+
throw new Error("--clear-parent belongs to `cockpit docs update`.");
|
|
55
|
+
}
|
|
56
|
+
const title = optionalNonEmpty(values.flags.get("--title"));
|
|
57
|
+
if (action === "create" && !title) {
|
|
58
|
+
throw new Error("docs create needs --title.");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
kind: "docs",
|
|
62
|
+
action,
|
|
63
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
64
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
65
|
+
...(docRef ? { docRef } : {}),
|
|
66
|
+
title,
|
|
67
|
+
...(parentId ? { parentId } : {}),
|
|
68
|
+
clearParent,
|
|
69
|
+
visibility: visibility,
|
|
70
|
+
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
71
|
+
bodyStdin: values.booleans.has("--body-stdin"),
|
|
72
|
+
json: values.booleans.has("--json"),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const MSG_ACTIONS = new Set(["channels", "read", "send", "thread"]);
|
|
76
|
+
const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send"]);
|
|
77
|
+
export function parseMsgArgs(args) {
|
|
78
|
+
const values = parseNamedArgs(args, {
|
|
79
|
+
allowedFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--json"],
|
|
80
|
+
valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit"],
|
|
81
|
+
});
|
|
82
|
+
const first = values.positionals[0];
|
|
83
|
+
const action = (first === undefined ? "channels" : first);
|
|
84
|
+
if (!MSG_ACTIONS.has(action)) {
|
|
85
|
+
throw new Error(`Unknown msg command: ${first}. Try channels, read, send, or thread.`);
|
|
86
|
+
}
|
|
87
|
+
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
88
|
+
let channelRef;
|
|
89
|
+
if (MSG_ACTIONS_NEEDING_A_CHANNEL.has(action)) {
|
|
90
|
+
channelRef = optionalNonEmpty(rest[0]);
|
|
91
|
+
if (!channelRef)
|
|
92
|
+
throw new Error(`msg ${action} needs a channel id or name.`);
|
|
93
|
+
if (rest.length > 1)
|
|
94
|
+
throw new Error(`msg ${action} takes one channel, not ${rest.length}.`);
|
|
95
|
+
}
|
|
96
|
+
else if (action === "thread") {
|
|
97
|
+
// `thread <id> --channel <channel>` — the channel is a flag here because
|
|
98
|
+
// the positional names the THREAD, not the channel (`--channel` below).
|
|
99
|
+
if (rest.length > 1)
|
|
100
|
+
throw new Error(`msg thread takes one thread id, not ${rest.length}.`);
|
|
101
|
+
}
|
|
102
|
+
else if (rest.length > 0) {
|
|
103
|
+
throw new Error(`msg ${action} does not take "${rest[0]}".`);
|
|
104
|
+
}
|
|
105
|
+
const threadFlag = optionalNonEmpty(values.flags.get("--thread"));
|
|
106
|
+
const threadId = action === "thread" ? optionalNonEmpty(rest[0]) : threadFlag;
|
|
107
|
+
if (action === "thread" && !threadId)
|
|
108
|
+
throw new Error("msg thread needs a thread id.");
|
|
109
|
+
const channelFlag = optionalNonEmpty(values.flags.get("--channel"));
|
|
110
|
+
if (action === "thread")
|
|
111
|
+
channelRef = channelFlag;
|
|
112
|
+
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
113
|
+
if (limit !== undefined && action !== "read" && action !== "thread") {
|
|
114
|
+
throw new Error("--limit belongs to `cockpit msg read` or `cockpit msg thread`.");
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
kind: "msg",
|
|
118
|
+
action,
|
|
119
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
120
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
121
|
+
...(channelRef ? { channelRef } : {}),
|
|
122
|
+
...(threadId ? { threadId } : {}),
|
|
123
|
+
...(limit === undefined ? {} : { limit }),
|
|
124
|
+
json: values.booleans.has("--json"),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* local-args-tower-admin.ts scout, ops, slack, — managing the account or
|
|
17
17
|
* settings, team, watching the pipeline
|
|
18
18
|
* model
|
|
19
|
+
* local-args-tower-docs-msg.ts docs, msg — the document library and
|
|
20
|
+
* channels/messages (BLI-3706)
|
|
19
21
|
*
|
|
20
22
|
* Every name this module has ever exported is still importable from here.
|
|
21
23
|
*/
|
|
22
24
|
export { parseJarvisArgs, parseCorrectArgs } from "./local-args-tower-chat.js";
|
|
23
25
|
export { parseBriefArgs, WORKBOOK_MIN_WIDTH, parseWorkbookArgs, parseNotesArgs, } from "./local-args-tower-pages.js";
|
|
24
|
-
export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
|
|
26
|
+
export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
|
|
27
|
+
export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
2
|
-
import { parseBriefArgs, parseCorrectArgs, parseJarvisArgs, parseModelArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
2
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
3
3
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
4
4
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
5
5
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -84,6 +84,10 @@ export function parseLocalArgs(argv) {
|
|
|
84
84
|
return parseMemoryArgs(argv.slice(1));
|
|
85
85
|
case "clean":
|
|
86
86
|
return parseCleanArgs(argv.slice(1));
|
|
87
|
+
case "docs":
|
|
88
|
+
return parseDocsArgs(argv.slice(1));
|
|
89
|
+
case "msg":
|
|
90
|
+
return parseMsgArgs(argv.slice(1));
|
|
87
91
|
case "release":
|
|
88
92
|
return parseReleaseArgs(argv.slice(1));
|
|
89
93
|
default:
|
|
@@ -40,6 +40,8 @@ export const rootCommandNames = new Set([
|
|
|
40
40
|
"agent-rules",
|
|
41
41
|
"memory",
|
|
42
42
|
"clean",
|
|
43
|
+
"docs",
|
|
44
|
+
"msg",
|
|
43
45
|
"release",
|
|
44
46
|
]);
|
|
45
47
|
export function localCommandHelp(command) {
|
|
@@ -78,6 +80,8 @@ export function localCommandHelp(command) {
|
|
|
78
80
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
79
81
|
" cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
80
82
|
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
83
|
+
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|--clear-parent] [--visibility org|private] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
84
|
+
" cockpit msg [channels|read <channel>|send <channel>|thread <id> --channel <channel>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
|
|
81
85
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
82
86
|
"",
|
|
83
87
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
@@ -580,6 +584,38 @@ function localSubcommandHelp(command) {
|
|
|
580
584
|
"right now.",
|
|
581
585
|
],
|
|
582
586
|
],
|
|
587
|
+
[
|
|
588
|
+
"docs",
|
|
589
|
+
[
|
|
590
|
+
"Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
|
|
591
|
+
"",
|
|
592
|
+
"The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
|
|
593
|
+
"list — every document you may see: id, visibility, slug, title.",
|
|
594
|
+
"tree — the same documents nested under their parent, for a sidebar-shaped view.",
|
|
595
|
+
"read <id|slug> — one document's title, slug, visibility, and its body.",
|
|
596
|
+
"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.",
|
|
597
|
+
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb.",
|
|
598
|
+
"A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
|
|
599
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
600
|
+
"A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
|
|
601
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
602
|
+
],
|
|
603
|
+
],
|
|
604
|
+
[
|
|
605
|
+
"msg",
|
|
606
|
+
[
|
|
607
|
+
"Usage: cockpit msg [channels|read <channel>|send <channel>|thread <id> --channel <channel>] [flags]",
|
|
608
|
+
"",
|
|
609
|
+
"Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
|
|
610
|
+
"channels — every channel you are a member of (or, as a super_admin, every channel).",
|
|
611
|
+
"read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
|
|
612
|
+
"send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
|
|
613
|
+
"thread <id> --channel <channel> — one thread's replies by the parent message's id.",
|
|
614
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
615
|
+
"A refusal keeps Tower's own reason label — needs_rls_client, channel_not_found_or_unreadable, content_too_long, and so on.",
|
|
616
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
617
|
+
],
|
|
618
|
+
],
|
|
583
619
|
[
|
|
584
620
|
"release",
|
|
585
621
|
[
|
package/dist/commands/local.js
CHANGED
|
@@ -32,6 +32,8 @@ import { runAutostart } from "./autostart-command.js";
|
|
|
32
32
|
import { runAgentRules } from "./agent-rules-command.js";
|
|
33
33
|
import { runMemoryInstall } from "./memory-install.js";
|
|
34
34
|
import { runClean } from "./clean.js";
|
|
35
|
+
import { runDocs } from "./docs.js";
|
|
36
|
+
import { runMsg } from "./msg.js";
|
|
35
37
|
import { parseLocalArgs } from "./local-args.js";
|
|
36
38
|
// `./local.js` is the published entry point for this command surface: the
|
|
37
39
|
// public CLI's generated root, commands/root.ts, doctor.ts and the test suite
|
|
@@ -131,6 +133,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
131
133
|
return await runMemoryInstall(command, io);
|
|
132
134
|
case "clean":
|
|
133
135
|
return await runClean(command, io);
|
|
136
|
+
case "docs":
|
|
137
|
+
return await runDocs(command, io);
|
|
138
|
+
case "msg":
|
|
139
|
+
return await runMsg(command, io);
|
|
134
140
|
case "release":
|
|
135
141
|
return await runRelease(command, io);
|
|
136
142
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an MCP server's bin lives on THIS machine, for any server shipped as a
|
|
3
|
+
* dependency of `@bli-cockpit/cli` (BLI-3580's `resolveMemoryMcpBin`,
|
|
4
|
+
* generalised for BLI-3706's `bli-tower` registration so the second server
|
|
5
|
+
* does not carry a second copy of this walk).
|
|
6
|
+
*
|
|
7
|
+
* Two steps, in this order — see `memory-install.ts`'s `resolveMemoryMcpBin`
|
|
8
|
+
* doc comment for the full BLI-3580 story of why the walk starts at THIS
|
|
9
|
+
* module's own file (never a shim) and realpath-resolves every anchor before
|
|
10
|
+
* walking:
|
|
11
|
+
*
|
|
12
|
+
* 1. Beside the CLI that is running — the ONLY lookup that cannot find
|
|
13
|
+
* somebody else's copy of the bin.
|
|
14
|
+
* 2. PATH, as a fallback for a linked checkout or a hand-installed server.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
export async function resolveMcpBin(options) {
|
|
20
|
+
const exists = options.fileExists ?? defaultFileExists;
|
|
21
|
+
const beside = await resolveBesideCli(options, exists);
|
|
22
|
+
if (beside)
|
|
23
|
+
return { path: beside, source: "cli_dependency" };
|
|
24
|
+
const onPath = await resolveOnPath(options, exists);
|
|
25
|
+
return onPath ? { path: onPath, source: "path" } : null;
|
|
26
|
+
}
|
|
27
|
+
function besideAnchors(options) {
|
|
28
|
+
const anchors = [];
|
|
29
|
+
const own = currentModulePath();
|
|
30
|
+
if (own)
|
|
31
|
+
anchors.push(own);
|
|
32
|
+
const entry = options.cliEntryPoint ?? process.argv[1];
|
|
33
|
+
if (entry)
|
|
34
|
+
anchors.push(entry);
|
|
35
|
+
return anchors;
|
|
36
|
+
}
|
|
37
|
+
function currentModulePath() {
|
|
38
|
+
try {
|
|
39
|
+
return fileURLToPath(import.meta.url);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function resolveBesideCli(options, exists) {
|
|
46
|
+
const platformPath = options.platform === "win32" ? path.win32 : path.posix;
|
|
47
|
+
const extensions = binExtensions(options.platform);
|
|
48
|
+
const realpath = options.realpath ?? defaultRealpath;
|
|
49
|
+
for (const anchor of besideAnchors(options)) {
|
|
50
|
+
let directory = platformPath.dirname(realpath(platformPath.resolve(anchor)));
|
|
51
|
+
// Bounded walk: deep enough for `…/node_modules/@scope/pkg/dist/cli.js`
|
|
52
|
+
// plus a hoisted root above it, and it stops at the filesystem root anyway.
|
|
53
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
54
|
+
for (const extension of extensions) {
|
|
55
|
+
const candidate = platformPath.join(directory, "node_modules", ".bin", `${options.binName}${extension}`);
|
|
56
|
+
if (await exists(candidate))
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
const parent = platformPath.dirname(directory);
|
|
60
|
+
if (parent === directory)
|
|
61
|
+
break;
|
|
62
|
+
directory = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function defaultRealpath(value) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.realpathSync.native(value);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function resolveOnPath(options, exists) {
|
|
76
|
+
const platformPath = options.platform === "win32" ? path.win32 : path.posix;
|
|
77
|
+
const entries = (options.env["PATH"] ?? options.env["Path"] ?? "")
|
|
78
|
+
.split(platformPath.delimiter)
|
|
79
|
+
.map((entry) => entry.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
for (const extension of binExtensions(options.platform)) {
|
|
83
|
+
const candidate = platformPath.join(entry, `${options.binName}${extension}`);
|
|
84
|
+
if (await exists(candidate))
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/** npm writes `.cmd` (and `.ps1`) shims on Windows; POSIX gets the bare name. */
|
|
91
|
+
function binExtensions(platform) {
|
|
92
|
+
return platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
93
|
+
}
|
|
94
|
+
async function defaultFileExists(file) {
|
|
95
|
+
const { stat } = await import("node:fs/promises");
|
|
96
|
+
try {
|
|
97
|
+
return (await stat(file)).isFile();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|