@bli-cockpit/cli 0.2.133 → 0.2.135

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.
@@ -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}.",
@@ -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.135");
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.135",
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.61",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }