@bli-cockpit/cli 0.2.141 → 0.2.142

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.
@@ -49,6 +49,8 @@ export async function runDocs(command, io) {
49
49
  return createDoc(command, door);
50
50
  case "update":
51
51
  return updateDoc(command, door);
52
+ case "delete":
53
+ return deleteDocs(command, door);
52
54
  case "link":
53
55
  return linkDoc(command, door);
54
56
  case "sync-status":
@@ -234,6 +236,75 @@ async function updateDoc(command, door) {
234
236
  writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
235
237
  return 0;
236
238
  }
239
+ /**
240
+ * BLI‑10555: soft-deletes one or more documents — `DELETE
241
+ * /api/docs/documents/[id]`, the same door the browser's own delete calls.
242
+ * Recoverable (only a super_admin clearing `deleted_at` brings it back, no
243
+ * self-service undo) and never cascades: a deleted document's children keep
244
+ * their own `parent_id` and stay exactly where they were.
245
+ *
246
+ * Mirrors `readDoc`'s multi-ref shape (BLI-10450): one shared `docs list`
247
+ * resolves every ref, then one `DELETE` per document — one bad ref never
248
+ * loses the rest of the batch.
249
+ */
250
+ async function deleteDocs(command, door) {
251
+ const refs = command.docRefs && command.docRefs.length > 0 ? command.docRefs : [command.docRef ?? ""];
252
+ const resolved = await resolveDocIds(door, refs);
253
+ if (resolved.status === "list_failed")
254
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
255
+ const results = await Promise.all(refs.map((ref) => deleteOneDocument(door, ref, resolved.ids.get(ref))));
256
+ const failed = results.filter((result) => !result.ok);
257
+ writeLine(door.io.stderr, `${TAG} delete ${JSON.stringify({ requested: refs.length, deleted: results.length - failed.length, refused: failed.length })}`);
258
+ if (refs.length === 1) {
259
+ const [result] = results;
260
+ if (!result || !result.ok) {
261
+ return failAgentDoor(door, TAG, result?.reason ?? "document_not_found_or_unreadable", result?.detail ?? "Not found.");
262
+ }
263
+ if (door.json)
264
+ return emitAgentDoor(door, { ok: true, document_id: result.id });
265
+ writeLine(door.io.stdout, `Deleted ${result.id}. Recoverable by a super_admin; its children stay in place.`);
266
+ return 0;
267
+ }
268
+ if (door.json) {
269
+ return emitAgentDoor(door, {
270
+ ok: failed.length === 0,
271
+ documents: results.map((result) => result.ok
272
+ ? { ref: result.ref, ok: true, document_id: result.id }
273
+ : { ref: result.ref, ok: false, error: result.reason, detail: result.detail }),
274
+ }, failed.length === 0 ? 0 : 1);
275
+ }
276
+ results.forEach((result) => {
277
+ if (result.ok) {
278
+ writeLine(door.io.stdout, `Deleted ${result.ref} (${result.id}).`);
279
+ }
280
+ else {
281
+ writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ ref: result.ref, reason: result.reason })}`);
282
+ writeLine(door.io.stdout, `${result.ref}: ${result.detail}`);
283
+ }
284
+ });
285
+ writeLine(door.io.stdout, "");
286
+ writeLine(door.io.stdout, `${results.length - failed.length}/${results.length} document(s) deleted. Recoverable by a super_admin; children stay in place.`);
287
+ return failed.length === 0 ? 0 : 1;
288
+ }
289
+ async function deleteOneDocument(door, ref, id) {
290
+ if (!id) {
291
+ return {
292
+ ref,
293
+ ok: false,
294
+ reason: "document_not_found_or_unreadable",
295
+ detail: `That document does not exist, or you cannot delete it: ${ref}`,
296
+ };
297
+ }
298
+ const answer = await askAgentDoor(door, {
299
+ path: `/api/docs/documents/${encodeURIComponent(id)}`,
300
+ method: "DELETE",
301
+ label: "docs delete",
302
+ timeoutMs: WRITE_DEADLINE_MS,
303
+ });
304
+ if (!answer.ok)
305
+ return { ref, ok: false, reason: answer.reason, detail: answer.detail };
306
+ return { ref, ok: true, id };
307
+ }
237
308
  async function updateDocument(command, door, documentId, bodyMarkdown) {
238
309
  return askAgentDoor(door, {
239
310
  path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
@@ -7,10 +7,12 @@
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", "link", "sync-status"]);
10
+ const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status"]);
11
11
  // BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
12
12
  // it takes SEVERAL references now, so it no longer shares the "exactly one"
13
- // rule `update`/`link` still enforce.
13
+ // rule `update`/`link` still enforce. BLI‑10555: `delete` takes several too
14
+ // (the same door, called once per ref), so it joined `read`'s branch rather
15
+ // than this set.
14
16
  const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link"]);
15
17
  export function parseDocsArgs(args) {
16
18
  const values = parseNamedArgs(args, {
@@ -52,6 +54,11 @@ export function parseDocsArgs(args) {
52
54
  const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
53
55
  if (action === "read")
54
56
  validateDocsReadTargets(docRefs, parentId, bodies);
57
+ // BLI‑10555: `delete` needs no `--parent`/`--bodies` shape, only at least
58
+ // one reference — `read`'s emptier validation does not apply.
59
+ if (action === "delete" && (!docRefs || docRefs.length === 0)) {
60
+ throw new Error("docs delete needs one or more document ids/slugs.");
61
+ }
55
62
  return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
56
63
  }
57
64
  /**
@@ -104,7 +111,7 @@ function parseDocsAction(positionals) {
104
111
  const first = positionals[0];
105
112
  const action = (first === undefined ? "list" : first);
106
113
  if (!DOCS_ACTIONS.has(action)) {
107
- throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, link, or sync-status.`);
114
+ throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, or sync-status.`);
108
115
  }
109
116
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
110
117
  }
@@ -112,8 +119,10 @@ function parseDocsDocumentReference(action, rest) {
112
119
  // BLI-10450: `read` alone takes several references in one call — an agent
113
120
  // reading a folder's worth of documents used to pay a resolve-then-read
114
121
  // pair PER document. Emptiness is validated later, once `--parent`/
115
- // `--bodies` are known (`validateDocsReadTargets`).
116
- if (action === "read") {
122
+ // `--bodies` are known (`validateDocsReadTargets`). BLI‑10555: `delete`
123
+ // shares this shape — several refs, one shared resolve, one DELETE each —
124
+ // its own emptiness check runs right after this function returns.
125
+ if (action === "read" || action === "delete") {
117
126
  const refs = rest.map((token) => optionalNonEmpty(token)).filter((token) => Boolean(token));
118
127
  return { docRef: refs[0], docRefs: refs.length > 0 ? refs : undefined };
119
128
  }
@@ -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> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|link <id|slug> <notion-page>|sync-status [<id|slug>]] [flags]",
45
+ "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|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> [--word]] [--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). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
@@ -59,6 +59,7 @@ export const TOWER_COMMAND_HELP = [
59
59
  "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 }.",
60
60
  "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. --json shape: { document }.",
61
61
  " --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.",
62
+ "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 — only a super_admin clearing deleted_at brings it back, there is no self-service undo. Deleting a parent does NOT delete its children; they keep their own parent_id and stay exactly where they were. Several refs resolve against ONE shared list call, 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 }] }.",
62
63
  "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.",
63
64
  "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.",
64
65
  "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
@@ -117,7 +117,7 @@ export function localCommandHelp(command) {
117
117
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
118
118
  " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
119
119
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
120
- " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
120
+ " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
121
121
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
122
122
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
123
123
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
@@ -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.141");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.142");
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.141",
3
+ "version": "0.2.142",
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.36",
31
- "@bli-cockpit/mcp": "0.1.66",
31
+ "@bli-cockpit/mcp": "0.1.67",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }