@bli-cockpit/cli 0.2.135 → 0.2.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit docs link` and `cockpit docs sync-status` — the Notion half of the
|
|
3
|
+
* document library (BLI-10430). A sibling of `docs.ts` rather than more of it:
|
|
4
|
+
* that file is the five library verbs, and this one is the two-way sync's.
|
|
5
|
+
*
|
|
6
|
+
* `link <id|slug> <notion-url|page-id>` → `PUT /api/docs/documents/[id]/notion-link`
|
|
7
|
+
* `link <id|slug> --clear` → `DELETE` on the same door
|
|
8
|
+
* `sync-status [<id|slug>]` → `GET /api/docs/notion-sync`
|
|
9
|
+
*
|
|
10
|
+
* The door folds any Notion URL or id to the page id the importer stores, and
|
|
11
|
+
* refuses by name: `invalid_notion_page`, `notion_page_already_linked`,
|
|
12
|
+
* `document_not_writable`. A link means the page is the truth: the next sync
|
|
13
|
+
* pulls it over the Tower copy and keeps the copy as a version
|
|
14
|
+
* (`docs/architecture/docs.md`, "Two-way sync with Notion").
|
|
15
|
+
*/
|
|
16
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
17
|
+
import { writeLine } from "./cli-io.js";
|
|
18
|
+
import { resolveDocId } from "./docs.js";
|
|
19
|
+
const TAG = "[docs cli]";
|
|
20
|
+
const READ_DEADLINE_MS = 30_000;
|
|
21
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
22
|
+
/**
|
|
23
|
+
* `sync-status [<id|slug>]` → `GET /api/docs/notion-sync[?document_id=]`. Tower's
|
|
24
|
+
* half of the answer only (synced / tower_changed / never_synced); the door
|
|
25
|
+
* never claims anything about Notion it has not read.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncStatusDocs(command, door) {
|
|
28
|
+
let path = "/api/docs/notion-sync";
|
|
29
|
+
if (command.docRef) {
|
|
30
|
+
const resolved = await resolveDocId(door, command.docRef);
|
|
31
|
+
if (resolved.status === "list_failed")
|
|
32
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
33
|
+
if (resolved.status === "not_found") {
|
|
34
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${command.docRef}`);
|
|
35
|
+
}
|
|
36
|
+
path = `${path}?document_id=${encodeURIComponent(resolved.id)}`;
|
|
37
|
+
}
|
|
38
|
+
const answer = await askAgentDoor(door, { path, method: "GET", label: "docs sync-status", timeoutMs: READ_DEADLINE_MS });
|
|
39
|
+
if (!answer.ok)
|
|
40
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
41
|
+
const body = answer.body;
|
|
42
|
+
if (door.json)
|
|
43
|
+
return emitAgentDoor(door, { ok: true, counts: body.counts, documents: body.documents ?? [] });
|
|
44
|
+
const documents = body.documents ?? [];
|
|
45
|
+
for (const doc of documents) {
|
|
46
|
+
writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
47
|
+
}
|
|
48
|
+
const byState = body.counts?.by_state ?? {};
|
|
49
|
+
writeLine(door.io.stdout, "");
|
|
50
|
+
writeLine(door.io.stdout, `${body.counts?.linked ?? documents.length} linked: ${Object.entries(byState)
|
|
51
|
+
.map(([state, count]) => `${count} ${state}`)
|
|
52
|
+
.join(", ")}. A Notion edit shows up at the next pull, not here.`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
export async function linkDoc(command, door) {
|
|
56
|
+
const ref = command.docRef ?? "";
|
|
57
|
+
const resolved = await resolveDocId(door, ref);
|
|
58
|
+
if (resolved.status === "list_failed")
|
|
59
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
60
|
+
if (resolved.status === "not_found") {
|
|
61
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot write to it: ${ref}`);
|
|
62
|
+
}
|
|
63
|
+
const path = `/api/docs/documents/${encodeURIComponent(resolved.id)}/notion-link`;
|
|
64
|
+
const answer = command.clearLink
|
|
65
|
+
? await askAgentDoor(door, { path, method: "DELETE", label: "docs link", timeoutMs: WRITE_DEADLINE_MS })
|
|
66
|
+
: await askAgentDoor(door, {
|
|
67
|
+
path,
|
|
68
|
+
method: "PUT",
|
|
69
|
+
label: "docs link",
|
|
70
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
71
|
+
body: { notion_page: command.notionPage ?? "" },
|
|
72
|
+
});
|
|
73
|
+
if (!answer.ok)
|
|
74
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
75
|
+
const result = answer.body;
|
|
76
|
+
const document = result.document ?? {};
|
|
77
|
+
writeLine(door.io.stderr, `${TAG} link ${JSON.stringify({ document_id: document["id"] ?? resolved.id, outcome: result.outcome ?? null })}`);
|
|
78
|
+
if (door.json)
|
|
79
|
+
return emitAgentDoor(door, { ok: true, outcome: result.outcome, document });
|
|
80
|
+
const title = String(document["title"] ?? "");
|
|
81
|
+
const page = document["notion_page_id"];
|
|
82
|
+
const sentence = {
|
|
83
|
+
linked: `Linked ${title} to Notion page ${String(page)}. The next sync pulls the page over this copy and keeps the copy as a version.`,
|
|
84
|
+
already_linked: `${title} was already linked to Notion page ${String(page)}. Nothing changed.`,
|
|
85
|
+
unlinked: `Unlinked ${title} from Notion. It is a Tower-only document now.`,
|
|
86
|
+
already_unlinked: `${title} was not linked to Notion. Nothing changed.`,
|
|
87
|
+
};
|
|
88
|
+
writeLine(door.io.stdout, sentence[result.outcome ?? ""] ?? `Done: ${title}.`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
31
31
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
32
|
+
import { linkDoc, syncStatusDocs } from "./docs-notion.js";
|
|
32
33
|
import { readNoteFile } from "./notes-file.js";
|
|
33
34
|
const TAG = "[docs cli]";
|
|
34
35
|
const READ_DEADLINE_MS = 30_000;
|
|
@@ -47,6 +48,10 @@ export async function runDocs(command, io) {
|
|
|
47
48
|
return createDoc(command, door);
|
|
48
49
|
case "update":
|
|
49
50
|
return updateDoc(command, door);
|
|
51
|
+
case "link":
|
|
52
|
+
return linkDoc(command, door);
|
|
53
|
+
case "sync-status":
|
|
54
|
+
return syncStatusDocs(command, door);
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
/** `--parent`, `--query`, `--limit` as the door's own query parameters (BLI-3737). */
|
|
@@ -112,7 +117,7 @@ async function treeDocs(door) {
|
|
|
112
117
|
return 0;
|
|
113
118
|
}
|
|
114
119
|
/** Finds a document id by exact id or exact slug against the flat list. Never fuzzy. */
|
|
115
|
-
async function resolveDocId(door, ref) {
|
|
120
|
+
export async function resolveDocId(door, ref) {
|
|
116
121
|
const answer = await askAgentDoor(door, {
|
|
117
122
|
path: "/api/docs/documents",
|
|
118
123
|
method: "GET",
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* added both without stretching either existing family's own doc comment.
|
|
8
8
|
*/
|
|
9
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"]);
|
|
10
|
+
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "link", "sync-status"]);
|
|
11
|
+
const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["read", "update", "link"]);
|
|
12
12
|
export function parseDocsArgs(args) {
|
|
13
13
|
const values = parseNamedArgs(args, {
|
|
14
14
|
allowedFlags: [
|
|
@@ -23,20 +23,46 @@ export function parseDocsArgs(args) {
|
|
|
23
23
|
"--body-stdin",
|
|
24
24
|
"--query",
|
|
25
25
|
"--limit",
|
|
26
|
+
"--clear",
|
|
26
27
|
"--json",
|
|
27
28
|
],
|
|
28
29
|
valueFlags: ["--home", "--dashboard-url", "--title", "--parent", "--visibility", "--file", "--query", "--limit"],
|
|
29
30
|
});
|
|
30
31
|
const { action, rest } = parseDocsAction(values.positionals);
|
|
31
|
-
const
|
|
32
|
+
const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
|
|
33
|
+
const docRef = parseDocsDocumentReference(action, link.rest);
|
|
32
34
|
const { visibility, clearParent, parentId, allowEmpty, title, query, limit } = parseDocsFlags(action, values);
|
|
33
|
-
return buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit);
|
|
35
|
+
return buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit, link);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* BLI-10430: `docs link <id|slug> <notion-url|page-id>` ties a document to a
|
|
39
|
+
* Notion page; `docs link <id|slug> --clear` unties it. Exactly one of the two:
|
|
40
|
+
* a link with neither says nothing, and one with both says two things.
|
|
41
|
+
*/
|
|
42
|
+
function parseDocsLink(action, rest, clear) {
|
|
43
|
+
if (action !== "link") {
|
|
44
|
+
if (clear)
|
|
45
|
+
throw new Error("--clear belongs to `cockpit docs link`.");
|
|
46
|
+
return { rest };
|
|
47
|
+
}
|
|
48
|
+
// No document at all: the document-reference check says so, first.
|
|
49
|
+
if (rest.length === 0)
|
|
50
|
+
return { rest };
|
|
51
|
+
const notionPage = optionalNonEmpty(rest[1]);
|
|
52
|
+
if (clear && notionPage)
|
|
53
|
+
throw new Error("docs link takes a Notion page OR --clear, not both.");
|
|
54
|
+
if (!clear && !notionPage) {
|
|
55
|
+
throw new Error("docs link needs a Notion page URL or id after the document, or --clear to unlink it.");
|
|
56
|
+
}
|
|
57
|
+
if (rest.length > 2)
|
|
58
|
+
throw new Error(`docs link takes a document and a Notion page, not ${rest.length} arguments.`);
|
|
59
|
+
return { rest: rest.slice(0, 1), ...(notionPage ? { notionPage } : {}), ...(clear ? { clearLink: true } : {}) };
|
|
34
60
|
}
|
|
35
61
|
function parseDocsAction(positionals) {
|
|
36
62
|
const first = positionals[0];
|
|
37
63
|
const action = (first === undefined ? "list" : first);
|
|
38
64
|
if (!DOCS_ACTIONS.has(action)) {
|
|
39
|
-
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, or
|
|
65
|
+
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, link, or sync-status.`);
|
|
40
66
|
}
|
|
41
67
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
42
68
|
}
|
|
@@ -49,6 +75,12 @@ function parseDocsDocumentReference(action, rest) {
|
|
|
49
75
|
throw new Error(`docs ${action} takes one document reference, not ${rest.length}.`);
|
|
50
76
|
return docRef;
|
|
51
77
|
}
|
|
78
|
+
// BLI-10430: `sync-status` reports the whole linked library, or one document.
|
|
79
|
+
if (action === "sync-status") {
|
|
80
|
+
if (rest.length > 1)
|
|
81
|
+
throw new Error(`docs sync-status takes at most one document reference, not ${rest.length}.`);
|
|
82
|
+
return optionalNonEmpty(rest[0]);
|
|
83
|
+
}
|
|
52
84
|
if (rest.length > 0)
|
|
53
85
|
throw new Error(`docs ${action} does not take "${rest[0]}".`);
|
|
54
86
|
return undefined;
|
|
@@ -92,7 +124,7 @@ function parseDocsFlags(action, values) {
|
|
|
92
124
|
}
|
|
93
125
|
return { visibility, clearParent, parentId, allowEmpty, title, query, limit };
|
|
94
126
|
}
|
|
95
|
-
function buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit) {
|
|
127
|
+
function buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit, link) {
|
|
96
128
|
return {
|
|
97
129
|
kind: "docs",
|
|
98
130
|
action,
|
|
@@ -108,6 +140,8 @@ function buildDocsCommand(values, action, docRef, visibility, clearParent, paren
|
|
|
108
140
|
bodyStdin: values.booleans.has("--body-stdin"),
|
|
109
141
|
...(query ? { query } : {}),
|
|
110
142
|
...(limit === undefined ? {} : { limit }),
|
|
143
|
+
...(link.notionPage ? { notionPage: link.notionPage } : {}),
|
|
144
|
+
...(link.clearLink ? { clearLink: true } : {}),
|
|
111
145
|
json: values.booleans.has("--json"),
|
|
112
146
|
};
|
|
113
147
|
}
|
|
@@ -42,7 +42,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
42
42
|
[
|
|
43
43
|
"docs",
|
|
44
44
|
[
|
|
45
|
-
"Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
|
|
45
|
+
"Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>|link <id|slug> <notion-page>|sync-status [<id|slug>]] [flags]",
|
|
46
46
|
"",
|
|
47
47
|
"The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
|
|
48
48
|
"list [--parent <id>|root] [--query <text>] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB).",
|
|
@@ -54,6 +54,8 @@ export const TOWER_COMMAND_HELP = [
|
|
|
54
54
|
"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.",
|
|
55
55
|
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb.",
|
|
56
56
|
" --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.",
|
|
57
|
+
"link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
|
|
58
|
+
"sync-status [<id|slug>] — every linked document's Notion sync state, or one's: synced (the body is what both sides last agreed on), tower_changed (edited here since; the next write-back pushes it), never_synced (linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull, not here. Metadata only, never a body.",
|
|
57
59
|
"A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
|
|
58
60
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
59
61
|
"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.",
|
|
@@ -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.137");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.137",
|
|
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.35",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.63",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.48"
|
|
33
33
|
}
|
|
34
34
|
}
|