@bli-cockpit/cli 0.2.133 → 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
+ }
@@ -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 docRef = parseDocsDocumentReference(action, rest);
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 update.`);
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
  }
@@ -16,6 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseRootsArgs } from "./roots.js";
19
+ import { SURFACE_ALIASES } from "./local-help.js";
19
20
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
20
21
  import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, } from "./local-args-tower.js";
21
22
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
@@ -29,6 +30,11 @@ export { normalizeUrl } from "./local-arg-values.js";
29
30
  export { DOCTOR_SETUP_ALIASES, } from "./local-command-shapes.js";
30
31
  export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, ISSUE_STATES, } from "./local-args-tower.js";
31
32
  export function parseLocalArgs(argv) {
33
+ // BLI-10431: a Tower page name (`work`, `calendar`, ...) is the same parser
34
+ // as the noun that serves the page — a second spelling, never a second door.
35
+ const aliasTarget = argv[0] ? SURFACE_ALIASES.get(argv[0]) : undefined;
36
+ if (aliasTarget)
37
+ return parseLocalArgs([aliasTarget, ...argv.slice(1)]);
32
38
  const command = argv[0];
33
39
  switch (command) {
34
40
  case "roots": return parseRootsArgs(argv.slice(1));
@@ -31,7 +31,7 @@ export const TOWER_COMMAND_HELP = [
31
31
  "list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
32
32
  "send-takehome <id> (also `invite <id>`): sends that role's take-home from the configured mailbox, whatever the screening scored. The same act as Tower's Send take-home button, and only a row still at `screened`.",
33
33
  "decide <id> --decision <passed_on|archived|reopen> — your own call on a row; `reopen` puts it back to `screened`.",
34
- "grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 15 minutes; --now says so rather than pretending the run happened here.",
34
+ "grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 5 minutes; --now says so rather than pretending the run happened here.",
35
35
  "resume <id> [--out <path>] [--force]: the candidate's resume, the same file the drawer's Open resume button shows. Bare, it prints a signed link that dies in 60 seconds; --out downloads the file to that path and refuses to overwrite one already there unless you pass --force.",
36
36
  "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
37
37
  "takehome set <role> [--subject <s>] [--body-file <path>] [--link <url>] [--pass-score <0-10>] — the body comes from a FILE and must keep {first_name} and {link}.",
@@ -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.",
@@ -17,7 +17,7 @@
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 { localCommandHelp } from "./local-help.js";
20
+ import { localCommandHelp, SURFACE_ALIASES } from "./local-help.js";
21
21
  import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
22
22
  export function localSubcommandHelp(command) {
23
23
  const helpByCommand = new Map([
@@ -662,5 +662,18 @@ export function localSubcommandHelp(command) {
662
662
  ],
663
663
  ],
664
664
  ]);
665
+ // BLI-10431: a Tower page name answers with its own usage line first, then
666
+ // the manual of the noun it routes to, so `cockpit work --help` names every
667
+ // Work verb instead of `Unknown command: work`.
668
+ const aliasTarget = SURFACE_ALIASES.get(command);
669
+ if (aliasTarget) {
670
+ return [
671
+ `Usage: cockpit ${command} [same verbs and flags as cockpit ${aliasTarget}]`,
672
+ `\`cockpit ${command}\` is the Tower page's name for \`cockpit ${aliasTarget}\`; both spellings run the same command.`,
673
+ ...(command === "work" ? ["Projects the issues are filed under: `cockpit project list`."] : []),
674
+ "",
675
+ ...(helpByCommand.get(aliasTarget) ?? [localCommandHelp()]),
676
+ ].join("\n");
677
+ }
665
678
  return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
666
679
  }
@@ -10,6 +10,26 @@
10
10
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
11
11
  import { SEARCH_KINDS } from "./local-args-tower-search.js";
12
12
  import { localSubcommandHelp } from "./local-help-commands.js";
13
+ /**
14
+ * The Tower PAGE names a person reads in the rail, each routed to the CLI noun
15
+ * that already serves that page (BLI-10431).
16
+ *
17
+ * The rail says WORK, CALENDAR, MESSAGES and MEETING NOTES; the MCP server
18
+ * says `work_list_issues`; the CLI spelled the same doors `issue`, `cal`,
19
+ * `msg` and `notes`. `cockpit work --help` answered `Unknown command: work` on
20
+ * 0.2.133 while every Work verb existed one word away. An alias here is a
21
+ * second spelling of the same parser, never a second parser: `cockpit work
22
+ * list` IS `cockpit issue list`, byte for byte.
23
+ *
24
+ * `surface-doors.test.ts` reads the rail and the MCP tool names and fails when
25
+ * a page or a tool family has no spelling here or in `rootCommandNames`.
26
+ */
27
+ export const SURFACE_ALIASES = new Map([
28
+ ["work", "issue"],
29
+ ["calendar", "cal"],
30
+ ["messages", "msg"],
31
+ ["meeting-notes", "notes"],
32
+ ]);
13
33
  export const rootCommandNames = new Set([
14
34
  "roots",
15
35
  "onboard",
@@ -55,6 +75,7 @@ export const rootCommandNames = new Set([
55
75
  "release",
56
76
  "careers",
57
77
  "usage",
78
+ ...SURFACE_ALIASES.keys(),
58
79
  ]);
59
80
  /** Public names omit compatibility spellings while the router keeps accepting them. */
60
81
  export const publicCommandNames = new Set([...rootCommandNames].filter((name) => name !== "do-everything" && name !== "fix"));
@@ -108,11 +129,66 @@ export function localCommandHelp(command) {
108
129
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
109
130
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
110
131
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
132
+ " cockpit work [same verbs and flags as cockpit issue] (the Work page; projects: `cockpit project list`)",
133
+ " cockpit calendar [same verbs and flags as cockpit cal]",
134
+ " cockpit messages [same verbs and flags as cockpit msg]",
135
+ " cockpit meeting-notes [same verbs and flags as cockpit notes]",
111
136
  " cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
112
137
  "",
113
138
  `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.`,
114
139
  ].join("\n");
115
140
  }
141
+ /**
142
+ * What an unknown first word answers (BLI-10431). The closest registered name
143
+ * within two edits, or none. A person who mistyped `work` as `wrok` wants one line
144
+ * naming `cockpit work`, not only a sixty-line wall to search by eye.
145
+ */
146
+ export function suggestCommand(word) {
147
+ const typed = word.toLowerCase();
148
+ let best = null;
149
+ for (const name of publicCommandNames) {
150
+ const distance = editDistance(typed, name);
151
+ if (distance > 2 || distance >= name.length)
152
+ continue;
153
+ if (!best || distance < best.distance)
154
+ best = { name, distance };
155
+ }
156
+ return best?.name ?? null;
157
+ }
158
+ /** The first line of an unknown-command refusal, with its suggestion if any. */
159
+ export function unknownCommandLine(word) {
160
+ const suggestion = suggestCommand(word);
161
+ return suggestion
162
+ ? `Unknown command: ${word}. Did you mean \`cockpit ${suggestion}\`?`
163
+ : `Unknown command: ${word}`;
164
+ }
165
+ /**
166
+ * The one JSON object `--json` promises, for an argv that never reached a
167
+ * command. Same shape as a Tower refusal (`ok`, `error`, `detail`), so an agent
168
+ * reads a refused parse and a refused door the same way.
169
+ */
170
+ export function unknownCommandJson(word) {
171
+ return JSON.stringify({
172
+ ok: false,
173
+ error: "unknown_command",
174
+ command: word,
175
+ did_you_mean: suggestCommand(word),
176
+ detail: `There is no cockpit command named ${word}. Run \`cockpit --help\` for every command.`,
177
+ });
178
+ }
179
+ function editDistance(a, b) {
180
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
181
+ for (let i = 1; i <= a.length; i += 1) {
182
+ let diagonal = row[0];
183
+ row[0] = i;
184
+ for (let j = 1; j <= b.length; j += 1) {
185
+ const above = row[j];
186
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
187
+ diagonal = above;
188
+ }
189
+ }
190
+ return row[b.length];
191
+ }
116
192
  /**
117
193
  * `cockpit <command> --help`, and since BLI-3833 `cockpit <command> <verb>
118
194
  * --help` too — QA tick 21 found `cockpit cal create --help` printing the whole
@@ -50,7 +50,7 @@ import { parseLocalArgs } from "./local-args.js";
50
50
  // `./local.js` is the published entry point for this command surface: the
51
51
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
52
52
  // all import from here. Splitting the file must not move a name off it.
53
- export { rootCommandNames, localCommandHelp } from "./local-help.js";
53
+ export { rootCommandNames, localCommandHelp, SURFACE_ALIASES, unknownCommandJson, unknownCommandLine, } from "./local-help.js";
54
54
  export { buildCollectorHeartbeat, collectionRootLabel, collectionRootLabels, sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
55
55
  export { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, SYNC_ERROR_DETAIL_MAX_CHARS, } from "./install-receipts.js";
56
56
  export { assertCollectionRootPersisted } from "./collection-roots.js";
@@ -66,6 +66,18 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
66
66
  command = parseLocalArgs(argv);
67
67
  }
68
68
  catch (error) {
69
+ // BLI-10431: under --json a refused parse still owes stdout one object.
70
+ // It used to write nothing there, so `cockpit usage sessions --json`
71
+ // without a filter read to an agent as an empty answer, not a refusal.
72
+ if (argv.includes("--json")) {
73
+ writeLine(io.stdout, JSON.stringify({
74
+ ok: false,
75
+ error: "invalid_arguments",
76
+ command: argv[0] ?? "",
77
+ detail: errorMessage(error),
78
+ }));
79
+ }
80
+ console.error("[cockpit-cli] arguments refused", JSON.stringify({ reason: "invalid_arguments", command: argv[0] ?? "", json: argv.includes("--json") }));
69
81
  writeLine(io.stderr, errorMessage(error));
70
82
  writeLine(io.stderr, "");
71
83
  // A refused `cockpit cal create --meet` used to print its one correct
@@ -85,6 +97,13 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
85
97
  // BLI-4110: the outermost stage name, so a crash that escapes every catch
86
98
  // below still says which subcommand was running.
87
99
  beginStage(`command:${command.kind}`);
100
+ // BLI-10431: `--json` promises one object on stdout even when the command
101
+ // throws. Watching whether stdout was written lets the net below add the
102
+ // refusal object only when the command had not already answered.
103
+ const wantsJson = "json" in command && command.json === true;
104
+ const stdoutWatch = wantsJson ? watchWrites(io.stdout) : undefined;
105
+ if (stdoutWatch)
106
+ io = { ...io, stdout: stdoutWatch.stream };
88
107
  try {
89
108
  switch (command.kind) {
90
109
  case "roots": return await runRoots(command, io);
@@ -206,6 +225,14 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
206
225
  command: command.kind,
207
226
  ...describeError(error),
208
227
  }));
228
+ if (stdoutWatch && !stdoutWatch.written()) {
229
+ writeLine(io.stdout, JSON.stringify({
230
+ ok: false,
231
+ error: "command_failed",
232
+ command: command.kind,
233
+ detail: errorMessage(error),
234
+ }));
235
+ }
209
236
  writeLine(io.stderr, errorMessage(error));
210
237
  return 1;
211
238
  }
@@ -239,4 +266,25 @@ async function resolveAndSaveDoctorRoots(command, io) {
239
266
  if (!command.json) {
240
267
  writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
241
268
  }
269
+ }
270
+ /**
271
+ * The same stream, with a flag that flips on the first write. A Proxy rather
272
+ * than a copied object so `isTTY`, `columns` and every listener method still
273
+ * reach the real stream.
274
+ */
275
+ function watchWrites(stream) {
276
+ let wrote = false;
277
+ const watched = new Proxy(stream, {
278
+ get(target, property) {
279
+ if (property === "write") {
280
+ return (...args) => {
281
+ wrote = true;
282
+ return target.write.apply(target, args);
283
+ };
284
+ }
285
+ const value = Reflect.get(target, property, target);
286
+ return typeof value === "function" ? value.bind(target) : value;
287
+ },
288
+ });
289
+ return { stream: watched, written: () => wrote };
242
290
  }
@@ -1,4 +1,10 @@
1
- import { localCommandHelp, runLocalCockpitCli, rootCommandNames } from "./local.js";
1
+ import {
2
+ localCommandHelp,
3
+ runLocalCockpitCli,
4
+ rootCommandNames,
5
+ unknownCommandJson,
6
+ unknownCommandLine,
7
+ } from "./local.js";
2
8
 
3
9
  export async function runCockpitCli(argv, io) {
4
10
  const command = argv[0];
@@ -15,7 +21,7 @@ export async function runCockpitCli(argv, io) {
15
21
  }
16
22
 
17
23
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.133");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.137");
19
25
  return 0;
20
26
  }
21
27
 
@@ -23,7 +29,12 @@ export async function runCockpitCli(argv, io) {
23
29
  return runLocalCockpitCli(argv, io);
24
30
  }
25
31
 
26
- writeLine(io?.stderr ?? process.stderr, `Unknown command: ${command}`);
32
+ // BLI-10431: mirrors commands/root.ts. `--json` owes stdout one object,
33
+ // and the refusal names the closest real command.
34
+ if (argv.includes("--json")) {
35
+ writeLine(io?.stdout ?? process.stdout, unknownCommandJson(command));
36
+ }
37
+ writeLine(io?.stderr ?? process.stderr, unknownCommandLine(command));
27
38
  writeLine(io?.stderr ?? process.stderr, "");
28
39
  writeLine(io?.stderr ?? process.stderr, cockpitHelp());
29
40
  return 1;
@@ -45,7 +56,7 @@ function cockpitHelp() {
45
56
  "Agent setup: `cockpit doctor` installs and verifies AGENTS.md/CLAUDE.md rules.",
46
57
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
47
58
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
48
- "Maintainer release path: merge to main, publish to `next` with `cockpit release`, canary Windows plus Apple Silicon, then deliberately promote that exact version to `latest`.",
59
+ "Maintainer release path: merge to main, then run `cockpit release --dry-run` and `cockpit release --tag latest` from a clean main checkout; a latest publish raises the fleet floor.",
49
60
  ].join("\n");
50
61
  }
51
62
 
@@ -122,11 +122,17 @@ export async function towerJsonRequest(options) {
122
122
  }
123
123
  return { ok: true, httpStatus: result.response.status, body };
124
124
  }
125
+ const UNREACHABLE_PREFIX = "Tower could not be reached (";
125
126
  /** One plain sentence per reason, so no caller has to invent its own wording. */
126
127
  export function towerFailureDetail(reason, detail) {
127
128
  switch (reason) {
128
129
  case "gateway_unreachable":
129
- return `Tower could not be reached (${detail}).`;
130
+ // Idempotent (BLI-10431): `towerRequest` already words its failure with
131
+ // this function, and seven callers word it again, which printed
132
+ // "Tower could not be reached (Tower could not be reached (fetch failed).)."
133
+ if (detail.startsWith(UNREACHABLE_PREFIX))
134
+ return detail;
135
+ return `${UNREACHABLE_PREFIX}${detail}).`;
130
136
  case "turn_timed_out":
131
137
  return "Tower did not finish in time, so the answer is incomplete. Ask again.";
132
138
  case "http_error":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.133",
3
+ "version": "0.2.137",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.35",
31
- "@bli-cockpit/mcp": "0.1.59",
31
+ "@bli-cockpit/mcp": "0.1.63",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }