@bli-cockpit/cli 0.2.99 → 0.2.101

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.
Files changed (38) hide show
  1. package/dist/agent-rules.js +2 -1
  2. package/dist/backfill-lock.js +1 -1
  3. package/dist/commands/backfill-checkpoint.js +3 -1
  4. package/dist/commands/backfill-issues.js +8 -55
  5. package/dist/commands/backfill-report.js +22 -6
  6. package/dist/commands/backfill-scan.js +2 -1
  7. package/dist/commands/backfill-skip-policy.js +134 -0
  8. package/dist/commands/careers.js +16 -0
  9. package/dist/commands/doctor-access.js +34 -10
  10. package/dist/commands/doctor-lock-wait.js +46 -0
  11. package/dist/commands/doctor-pipeline-verdicts.js +238 -0
  12. package/dist/commands/doctor-pipeline.js +49 -111
  13. package/dist/commands/doctor-registration.js +23 -2
  14. package/dist/commands/doctor-report.js +48 -9
  15. package/dist/commands/doctor-update.js +16 -5
  16. package/dist/commands/doctor.js +98 -58
  17. package/dist/commands/local-args-collector-setup.js +6 -0
  18. package/dist/commands/local-args-tower-careers.js +20 -0
  19. package/dist/commands/local-args-tower-pages.js +17 -2
  20. package/dist/commands/local-args-tower-usage.js +2 -2
  21. package/dist/commands/local-args-tower.js +2 -1
  22. package/dist/commands/local-args.js +3 -1
  23. package/dist/commands/local-help-commands-tower.js +4 -2
  24. package/dist/commands/local-help-commands.js +28 -12
  25. package/dist/commands/local-help.js +4 -2
  26. package/dist/commands/local.js +4 -0
  27. package/dist/commands/notes-file.js +8 -1
  28. package/dist/commands/notes-folders.js +35 -0
  29. package/dist/commands/notes-writes.js +37 -4
  30. package/dist/commands/notes.js +6 -0
  31. package/dist/commands/public-root.js +4 -4
  32. package/dist/commands/usage-format.js +18 -0
  33. package/dist/commands/usage.js +13 -3
  34. package/dist/cursors/backfill-completion-marker.js +135 -0
  35. package/dist/cursors/backfill-cursor.js +18 -99
  36. package/dist/scheduled-self-update.js +1 -1
  37. package/dist/sync-lock.js +15 -1
  38. package/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { isSemverBelow } from "../scheduled-self-update.js";
1
2
  import { DEFAULT_DASHBOARD_URL, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
3
  import { createInteractiveExecRunner } from "../process-runner.js";
3
4
  import { asRecord, fail, needsFix, ok } from "./doctor-report.js";
@@ -11,7 +12,7 @@ export async function checkCliLatest(context) {
11
12
  if (!latest) {
12
13
  return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
13
14
  }
14
- if (latest === LOCAL_COLLECTOR_VERSION) {
15
+ if (latest === LOCAL_COLLECTOR_VERSION || (!context.command.updateTag && /^\d+\.\d+\.\d+$/u.test(latest) && !isSemverBelow(LOCAL_COLLECTOR_VERSION, latest))) {
15
16
  return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
16
17
  }
17
18
  return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
@@ -28,7 +29,7 @@ export async function fixCliLatest(context, state) {
28
29
  }
29
30
  if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
30
31
  if (state.code === "stale_cli") {
31
- return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit do-everything`.");
32
+ return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit doctor`.");
32
33
  }
33
34
  return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
34
35
  }
@@ -67,9 +68,19 @@ function parseNpmVersion(stdout) {
67
68
  }
68
69
  }
69
70
  export function reexecDoctor(command, io) {
70
- const args = ["do-everything"];
71
- if (command.repoRoot)
72
- args.push("--workspace", command.repoRoot);
71
+ const args = ["doctor"];
72
+ if (command.homeDir)
73
+ args.push("--home", command.homeDir);
74
+ if (command.lockWaitSeconds)
75
+ args.push("--lock-wait", String(command.lockWaitSeconds));
76
+ if (command.allowHomeRoot)
77
+ args.push("--allow-home-root");
78
+ if (command.maxDepth)
79
+ args.push("--max-depth", String(command.maxDepth));
80
+ if (command.maxRepos)
81
+ args.push("--max-repos", String(command.maxRepos));
82
+ for (const root of command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []))
83
+ args.push("--workspace", root);
73
84
  if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
74
85
  args.push("--dashboard-url", command.dashboardUrl);
75
86
  }
@@ -1,11 +1,14 @@
1
+ import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
+ import { isSemverBelow } from "../scheduled-self-update.js";
3
+ import { describeError } from "../health-detail.js";
1
4
  import { withStage } from "../crash-guard.js";
2
5
  import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
3
- import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
6
+ import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
4
7
  import { checkMcpAnswersState } from "./doctor-mcp.js";
5
8
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
6
- import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
7
- import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
8
- import { refreshSetupReceipt } from "./setup-receipt.js";
9
+ import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
10
+ import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
11
+ import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
9
12
  import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
10
13
  export async function runDoctor(command, io, hooks, overrides = {}) {
11
14
  const deps = { ...defaultDoctorDeps(hooks), ...overrides };
@@ -14,58 +17,93 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
14
17
  export async function runDoctorWithDeps(command, io, deps) {
15
18
  const context = { command, io, deps };
16
19
  const rows = [];
20
+ const repairs = [];
21
+ const diagnoseOnly = command.dryRun || command.checkOnly;
22
+ const diagnosed = new Map();
17
23
  for (const invariant of doctorInvariants()) {
18
- const checked = await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context));
19
- if (checked.status === "ok" || checked.status === "skipped") {
20
- rows.push(checked);
21
- continue;
24
+ try {
25
+ diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
22
26
  }
23
- if (command.dryRun && invariant.fix) {
24
- rows.push(dryRunPreview(checked));
25
- continue;
27
+ catch (error) {
28
+ console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
29
+ diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
26
30
  }
27
- const canFix = Boolean(invariant.fix) &&
28
- (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
29
- if (!canFix || !invariant.fix) {
30
- rows.push(withoutAFix(checked));
31
- if (checked.hardStop)
32
- break;
33
- continue;
31
+ }
32
+ for (const invariant of doctorInvariants()) {
33
+ let row;
34
+ try {
35
+ row = diagnosed.get(invariant.id);
36
+ const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
37
+ const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
38
+ const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
39
+ (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
40
+ if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
41
+ row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
42
+ repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
43
+ // The replacement process owns the one final receipt, including JSON.
44
+ if (row.reexecExitCode !== undefined)
45
+ return row.reexecExitCode;
46
+ row = { ...row, fixed: row.status === "ok" };
47
+ }
48
+ else {
49
+ repairs.push({ step: invariant.id, outcome: diagnoseOnly ? "check_only" : row.status === "ok" ? "already_ok" : row.status === "skipped" ? "not_needed" : "needs_person", reason: row.code });
50
+ if (command.dryRun && invariant.fix && row.status !== "ok" && row.status !== "skipped")
51
+ row = dryRunPreview(row);
52
+ }
34
53
  }
35
- // BLI-4110: named so a crash inside a repair says WHICH repair. The
36
- // 2026-09-09 incident printed a bare `read ENOTCONN` stack and the only
37
- // way to place it was the log line that happened to precede it.
38
- const fixed = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, checked));
39
- rows.push({ ...fixed, fixed: fixed.status !== "fail" });
40
- if (fixed.hardStop)
41
- break;
42
- if (fixed.reexecExitCode !== undefined) {
43
- await maybeReportDoctorEvents(context, rows);
44
- writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
45
- return fixed.reexecExitCode;
54
+ catch (error) {
55
+ console.error(`[doctor] ${invariant.id} failed`, JSON.stringify({ reason: "step_threw", ...describeError(error) }));
56
+ row = { id: invariant.id, status: "fail", code: "step_threw", message: "The step could not finish. Run `cockpit doctor`." };
57
+ repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
46
58
  }
59
+ console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
60
+ rows.push(row);
47
61
  }
48
- await maybeReportDoctorEvents(context, rows);
49
- // Doctor is the surface a person opens when something is wrong, so it
50
- // re-READS the machine rather than quoting a cache — and caching what it
51
- // read is what keeps the 15-minute heartbeat from paying for the probe.
52
- writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
53
- return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
54
- }
55
- /**
56
- * What a broken row looks like when nothing can repair it from here.
57
- *
58
- * A check that says `needs_fix` and has no fix is still `needs_fix` — the
59
- * person is being told what to do. A check that says `fail` KEEPS that word
60
- * (BLI-3804): it used to be quietly rewritten to `needs_fix`, which turned the
61
- * run green and, because `doctor-report.ts` maps every non-`fail` row to `ok`
62
- * in the install-event ledger, threw the reason label away on the way to the
63
- * fleet as well. `hardStop` is untouched and still ends the walk.
64
- */
65
- function withoutAFix(checked) {
66
- if (checked.hardStop || checked.status === "fail")
67
- return checked;
68
- return { ...checked, status: "needs_fix" };
62
+ // Re-read host state after repairs. Sync is proven by its upload receipts;
63
+ // re-running its diagnostic would always request a new upload by design.
64
+ if (!diagnoseOnly) {
65
+ for (const invariant of doctorInvariants()) {
66
+ if (["cli-latest", "authed", "roots-ok", "backfill-complete", "sync-fresh", "gc-checked", "disk-bounded"].includes(invariant.id))
67
+ continue;
68
+ const index = rows.findIndex((row) => row.id === invariant.id);
69
+ if (rows[index]?.status === "fail")
70
+ continue;
71
+ try {
72
+ const checked = await invariant.check(context);
73
+ rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
74
+ console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
75
+ if (checked.status === "needs_fix" || checked.status === "fail") {
76
+ const repair = repairs.find((entry) => entry.step === invariant.id);
77
+ if (repair) {
78
+ repair.outcome = "needs_person";
79
+ repair.reason = checked.code;
80
+ }
81
+ }
82
+ }
83
+ catch (error) {
84
+ console.error(`[doctor] ${invariant.id} recheck_failed`, JSON.stringify({ reason: "recheck_threw", ...describeError(error) }));
85
+ rows[index] = { id: invariant.id, status: "fail", code: "recheck_threw", message: "Run `cockpit doctor` to retry the host check." };
86
+ }
87
+ }
88
+ }
89
+ const floor = await maybeReportDoctorEvents(context, rows);
90
+ if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
91
+ const index = rows.findIndex((row) => row.id === "cli-latest");
92
+ rows[index] = { id: "cli-latest", status: "needs_fix", code: "below_fleet_floor", message: `This process is below the fleet floor ${floor}. Run \`cockpit doctor\` to update.`, nextAction: "cockpit doctor" };
93
+ console.error("[doctor] cli-latest below_fleet_floor", JSON.stringify({ reason: "below_fleet_floor", current_version: LOCAL_COLLECTOR_VERSION, minimum_version: floor }));
94
+ }
95
+ let receipt = null;
96
+ if (!command.dryRun) {
97
+ try {
98
+ receipt = await deps.readSetupReceipt(context);
99
+ }
100
+ catch (error) {
101
+ console.error("[doctor] setup_receipt failed", JSON.stringify({ reason: "receipt_read_threw", ...describeError(error) }));
102
+ rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
103
+ }
104
+ }
105
+ writeDoctorOutput(command, io, rows, receipt, repairs);
106
+ return doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
69
107
  }
70
108
  function doctorInvariants() {
71
109
  return [
@@ -74,7 +112,6 @@ function doctorInvariants() {
74
112
  id: "authed",
75
113
  check: (context) => context.deps.readAuth(context),
76
114
  fix: fixAuthState,
77
- requiresInteractiveFix: true,
78
115
  },
79
116
  {
80
117
  id: "roots-ok",
@@ -99,6 +136,7 @@ function doctorInvariants() {
99
136
  check: (context) => context.deps.checkMemory(context),
100
137
  fix: (context, _state) => context.deps.fixMemory(context),
101
138
  },
139
+ { id: "agent-rules", check: (context) => context.deps.checkAgentRules(context), fix: (context) => context.deps.fixAgentRules(context) },
102
140
  // BLI-3804. Right after the registration row, because it asks the second
103
141
  // half of the same question: `memory-registered` proves the entry exists
104
142
  // and names a bin, this one proves the server behind it starts, speaks the
@@ -122,6 +160,11 @@ function doctorInvariants() {
122
160
  check: (context) => context.deps.checkBackfill(context),
123
161
  fix: (context, _state) => context.deps.fixBackfill(context),
124
162
  },
163
+ {
164
+ id: "sync-fresh",
165
+ check: (context) => context.deps.checkSync(context),
166
+ fix: (context, _state) => context.deps.fixSync(context),
167
+ },
125
168
  {
126
169
  id: "gc-checked",
127
170
  check: (context) => context.deps.checkGc(context),
@@ -136,15 +179,12 @@ function doctorInvariants() {
136
179
  check: (context) => context.deps.checkDisk(context),
137
180
  fix: (context, _state) => context.deps.fixDisk(context),
138
181
  },
139
- {
140
- id: "sync-fresh",
141
- check: (context) => context.deps.checkSync(context),
142
- fix: (context, _state) => context.deps.fixSync(context),
143
- },
144
182
  ];
145
183
  }
146
184
  function defaultDoctorDeps(hooks) {
147
185
  return {
186
+ checkAgentRules: checkDoctorAgentRules,
187
+ fixAgentRules: fixDoctorAgentRules,
148
188
  latestCliVersion: latestCliVersionFromNpm,
149
189
  selfUpdate: hooks.selfUpdate,
150
190
  reexecDoctor: reexecDoctor,
@@ -168,7 +208,7 @@ function defaultDoctorDeps(hooks) {
168
208
  fixDisk: fixDiskState,
169
209
  checkSync: checkSyncState,
170
210
  fixSync: fixSyncState,
171
- readSetupReceipt: (context) => refreshSetupReceipt(context.io, {
211
+ readSetupReceipt: (context) => (context.command.checkOnly ? buildSetupReceipt : refreshSetupReceipt)(context.io, {
172
212
  ...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
173
213
  ...(context.command.dashboardUrl
174
214
  ? { dashboardUrl: context.command.dashboardUrl }
@@ -178,4 +218,4 @@ function defaultDoctorDeps(hooks) {
178
218
  }
179
219
  // Re-exported so every consumer keeps importing from `./doctor.js` regardless
180
220
  // of which sibling a symbol now lives in.
181
- export { backfillCompletionStepState, syncBacklogDrainingVerdict };
221
+ export { backfillCompletionStepState, backfillFixVerdict, syncBacklogDrainingVerdict, syncStandAsideVerdict, };
@@ -98,6 +98,9 @@ export function parseDoctorArgs(alias, args) {
98
98
  "--dashboard-url",
99
99
  "--update-tag",
100
100
  "--dry-run",
101
+ "--check",
102
+ "--no-repair",
103
+ "--lock-wait",
101
104
  "--json",
102
105
  "--allow-home-root",
103
106
  "--max-depth",
@@ -113,6 +116,7 @@ export function parseDoctorArgs(alias, args) {
113
116
  "--timeout-ms",
114
117
  ],
115
118
  valueFlags: [
119
+ "--lock-wait",
116
120
  "--home",
117
121
  "--repo",
118
122
  "--workspace",
@@ -135,6 +139,8 @@ export function parseDoctorArgs(alias, args) {
135
139
  }
136
140
  return {
137
141
  kind: "doctor",
142
+ checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
143
+ lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
138
144
  alias,
139
145
  homeDir: optionalNonEmpty(values.flags.get("--home")),
140
146
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
@@ -0,0 +1,20 @@
1
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
+ export function parseCareersArgs(args) {
3
+ const values = parseNamedArgs(args, { allowedFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--json'], valueFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url'] });
4
+ const action = values.positionals[0] ?? 'list';
5
+ if (action !== 'list' && action !== 'show' && action !== 'rescreen')
6
+ throw new Error('careers takes list, show, or rescreen.');
7
+ const id = values.positionals[1];
8
+ if (action !== 'list' && !id)
9
+ throw new Error(`careers ${action} needs an application id.`);
10
+ if (values.positionals.length > (action === 'list' ? 1 : 2))
11
+ throw new Error('Unexpected careers argument.');
12
+ const rawScore = values.flags.get('--min-score');
13
+ const minScore = rawScore === undefined ? undefined : Number(rawScore);
14
+ if (minScore !== undefined && (!Number.isFinite(minScore) || minScore < 0 || minScore > 100))
15
+ throw new Error('--min-score must be 0 to 100.');
16
+ const since = optionalNonEmpty(values.flags.get('--since'));
17
+ if (since && !Number.isFinite(Date.parse(since)))
18
+ throw new Error('--since must be a date.');
19
+ return { kind: 'careers', action, id, role: optionalNonEmpty(values.flags.get('--role')), minScore, since: since ? new Date(since).toISOString() : undefined, homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json') };
20
+ }
@@ -153,6 +153,7 @@ export function parseBriefArgs(args) {
153
153
  };
154
154
  }
155
155
  const NOTES_ACTIONS = new Set([
156
+ "folders", "mkdir", "rmdir", "rename",
156
157
  "list",
157
158
  "show",
158
159
  "shelf",
@@ -175,12 +176,14 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
175
176
  export function parseNotesArgs(args) {
176
177
  const values = parseNamedArgs(args, {
177
178
  allowedFlags: [
179
+ "--wait",
178
180
  "--home",
179
181
  "--dashboard-url",
180
182
  "--file",
181
183
  "--name",
182
184
  "--exclude",
183
185
  "--to",
186
+ "--folder",
184
187
  "--clear-shelf",
185
188
  "--apply",
186
189
  "--series",
@@ -190,6 +193,7 @@ export function parseNotesArgs(args) {
190
193
  "--limit",
191
194
  "--yes",
192
195
  "--json",
196
+ "--tree",
193
197
  ],
194
198
  valueFlags: [
195
199
  "--home",
@@ -198,6 +202,7 @@ export function parseNotesArgs(args) {
198
202
  "--name",
199
203
  "--exclude",
200
204
  "--to",
205
+ "--folder",
201
206
  "--series",
202
207
  "--kind",
203
208
  "--since",
@@ -236,23 +241,33 @@ export function parseNotesArgs(args) {
236
241
  if (paths.length === 0)
237
242
  throw new Error("notes upload needs at least one file path.");
238
243
  }
244
+ else if (action === "mkdir" || action === "rmdir" || action === "rename") {
245
+ if (rest.length !== 1 || !rest[0]?.trim())
246
+ throw new Error(`notes ${action} needs one folder path.`);
247
+ }
239
248
  else if (action !== "paste" && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action) && rest.length > 0) {
240
249
  throw new Error(`notes ${action} does not take "${rest[0]}".`);
241
250
  }
251
+ if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
252
+ throw new Error("notes rename needs --name <name>.");
242
253
  const to = optionalNonEmpty(values.flags.get("--to"));
243
254
  const clearShelf = values.booleans.has("--clear-shelf");
244
255
  if (action === "move") {
245
256
  if (to && clearShelf) {
246
257
  throw new Error("notes move accepts either --to or --clear-shelf, not both.");
247
258
  }
248
- if (!to && !clearShelf) {
249
- throw new Error('notes move needs --to "<shelf>" or --clear-shelf.');
259
+ if (!to && !clearShelf && !values.flags.get("--folder")) {
260
+ throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
250
261
  }
251
262
  }
252
263
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
253
264
  return {
254
265
  kind: "notes",
266
+ ...(values.booleans.has("--wait") ? { wait: true } : {}),
255
267
  action,
268
+ ...(values.flags.has("--folder") ? { folder: values.flags.get("--folder") } : {}),
269
+ ...(action === "mkdir" || action === "rmdir" || action === "rename" ? { folder: rest[0] } : {}),
270
+ ...(values.booleans.has("--tree") ? { tree: true } : {}),
256
271
  homeDir: optionalNonEmpty(values.flags.get("--home")),
257
272
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
258
273
  ...(noteId ? { noteId } : {}),
@@ -1,8 +1,8 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  export function parseUsageArgs(args) {
3
- const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
3
+ const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
4
4
  const action = values.positionals[0] ?? "people";
5
5
  if (action !== "people" || values.positionals.length > 1)
6
6
  throw new Error("usage takes one verb: people.");
7
- return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
7
+ return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
8
8
  }
@@ -36,4 +36,5 @@ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
36
36
  export { parseModelsArgs } from "./local-args-tower-models.js";
37
37
  export { parseMailArgs } from "./local-args-tower-mail.js";
38
38
  export { parseCalArgs } from "./local-args-tower-cal.js";
39
- export { parseUsageArgs } from "./local-args-tower-usage.js";
39
+ export { parseUsageArgs } from "./local-args-tower-usage.js";
40
+ export { parseCareersArgs } from './local-args-tower-careers.js';
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, } from "./local-args-tower.js";
19
+ 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";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -115,6 +115,8 @@ export function parseLocalArgs(argv) {
115
115
  return parseSearchArgs(argv.slice(1));
116
116
  case "release":
117
117
  return parseReleaseArgs(argv.slice(1));
118
+ case "careers":
119
+ return parseCareersArgs(argv.slice(1));
118
120
  case "usage":
119
121
  return parseUsageArgs(argv.slice(1));
120
122
  default:
@@ -22,6 +22,7 @@ const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
22
22
  const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
23
23
  /** One entry per Tower noun, in the order `cockpit --help` lists them. */
24
24
  export const TOWER_COMMAND_HELP = [
25
+ ["careers", ["Usage: cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]", "Super-admin application review. Lists at most 100 matches with total and has_more; use filters to narrow."]],
25
26
  [
26
27
  "docs",
27
28
  [
@@ -142,12 +143,13 @@ export const TOWER_COMMAND_HELP = [
142
143
  [
143
144
  "usage",
144
145
  [
145
- "Usage: cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--json]",
146
+ "Usage: cockpit usage people [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
146
147
  "",
147
148
  "Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
148
149
  "and the input / cache split when the row carries it), and an API list-price equivalent that is",
149
150
  "labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
150
- "--since takes 7d, 30d, 90d or an ISO timestamp; --until an ISO timestamp (default now).",
151
+ "--since takes <n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp; --until an ISO timestamp (default now).",
152
+ "--detail adds Output, Input, Cache read and Cache creation. Counts use K/M/B; dollars are rounded.",
151
153
  "--include-automated adds harness, subagent and scheduled sessions, which are excluded by default.",
152
154
  "It presses the same door as the /usage page and the usage_people MCP tool (GET /api/usage/people).",
153
155
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -71,30 +71,40 @@ export function localSubcommandHelp(command) {
71
71
  [
72
72
  "do-everything",
73
73
  [
74
- "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
74
+ "Usage: cockpit do-everything [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
75
75
  "",
76
- "Gets this machine fully set up, whether it is brand new, already set up, or handed down: latest CLI, sign-in and collection-root recovery when needed, saved folders, background sync, catching up on old sessions, cleaning up old files, and one fresh upload.",
77
- "`cockpit fix` is an alias.",
78
- "Maintainers only: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
79
- "--dry-run shows what it would do without changing anything.",
76
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
77
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
78
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
79
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
80
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
81
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
80
82
  ],
81
83
  ],
82
84
  [
83
85
  "fix",
84
86
  [
85
- "Usage: cockpit fix [same flags as cockpit do-everything]",
87
+ "Usage: cockpit fix [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
86
88
  "",
87
- "Alias for `cockpit do-everything`.",
89
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
90
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
91
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
92
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
93
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
94
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
88
95
  ],
89
96
  ],
90
97
  [
91
98
  "doctor",
92
99
  [
93
- "Usage: cockpit doctor [same flags as cockpit do-everything]",
100
+ "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
94
101
  "",
95
- "Alias for `cockpit do-everything`. It checks this machine row by row —",
96
- "CLI version, sign-in, saved folders, background sync, backfill, disk and",
97
- "fixes each row it can. `--dry-run` prints the diagnosis and changes nothing.",
102
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
103
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
104
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
105
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
106
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
107
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
98
108
  ],
99
109
  ],
100
110
  [
@@ -450,8 +460,14 @@ export function localSubcommandHelp(command) {
450
460
  [
451
461
  "notes",
452
462
  [
453
- "Usage: cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [flags]",
463
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>] [flags]",
464
+ "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
454
465
  "",
466
+ "folders [--tree] [--json]: list folders and visible note counts.",
467
+ "mkdir <path> [--json]: create a folder and any missing ancestors.",
468
+ "rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
469
+ "rmdir <path> [--json]: delete an empty folder; refuses notes or subfolders.",
470
+ "upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
455
471
  "The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
456
472
  "list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
457
473
  "show <id> — one note with its text, its shelf, and why you are allowed to see it.",
@@ -52,6 +52,7 @@ export const rootCommandNames = new Set([
52
52
  "project",
53
53
  "search",
54
54
  "release",
55
+ "careers",
55
56
  "usage",
56
57
  ]);
57
58
  export function localCommandHelp(command) {
@@ -63,7 +64,7 @@ export function localCommandHelp(command) {
63
64
  " cockpit upgrade [same flags as update]",
64
65
  " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
65
66
  " cockpit fix [same flags as do-everything]",
66
- " cockpit doctor [same flags as do-everything]",
67
+ " cockpit doctor [--check | --no-repair] [--lock-wait <seconds>] [--json]",
67
68
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
68
69
  " cockpit login [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
69
70
  " cockpit pair [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
@@ -83,7 +84,7 @@ export function localCommandHelp(command) {
83
84
  " cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
84
85
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
85
86
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
86
- " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>|place <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
87
+ " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
87
88
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
88
89
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
89
90
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -100,6 +101,7 @@ export function localCommandHelp(command) {
100
101
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
101
102
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
102
103
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
104
+ " cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
103
105
  " cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
104
106
  "",
105
107
  `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.`,
@@ -43,6 +43,7 @@ import { runCal } from "./cal.js";
43
43
  import { runMail } from "./mail.js";
44
44
  import { runProject } from "./project.js";
45
45
  import { runSearch } from "./search.js";
46
+ import { runCareers } from "./careers.js";
46
47
  import { runUsage } from "./usage.js";
47
48
  import { parseLocalArgs } from "./local-args.js";
48
49
  // `./local.js` is the published entry point for this command surface: the
@@ -180,6 +181,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
180
181
  return await runSearch(command, io);
181
182
  case "release":
182
183
  return await runRelease(command, io);
184
+ case "careers":
185
+ return await runCareers(command, io);
183
186
  case "usage":
184
187
  return await runUsage(command, io);
185
188
  }
@@ -206,6 +209,7 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
206
209
  async function runDoctorLogin(command, io) {
207
210
  return runLogin({
208
211
  kind: "login",
212
+ homeDir: command.homeDir,
209
213
  dashboardUrl: command.dashboardUrl,
210
214
  json: command.json,
211
215
  noAuth: false,
@@ -34,6 +34,10 @@ import { errorMessage } from "./cli-io.js";
34
34
  * courtesy check to avoid uploading something the server is certain to refuse,
35
35
  * not a second source of truth.
36
36
  */
37
+ export function noteAudioMime(name) {
38
+ const mime = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
39
+ return mime[name.split(".").at(-1)?.toLowerCase() ?? ""];
40
+ }
37
41
  export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
38
42
  /**
39
43
  * Above this, the terminal says it is working before it starts. The route's own
@@ -52,7 +56,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
52
56
  case "file_empty":
53
57
  return `There is nothing in that file: ${filePath}`;
54
58
  case "file_too_big":
55
- return "That file is too big to put in as a note keep it under 20 MB.";
59
+ return "That file is too big. Audio is limited to 4 MB; other notes to 20 MB.";
56
60
  case "looks_like_a_key_file":
57
61
  // The same rule the server's gate applies, said the same way: the name is
58
62
  // all it takes to decide, and looking inside to be sure would already be
@@ -88,6 +92,9 @@ export async function readNoteFile(filePath) {
88
92
  }
89
93
  if (size === 0)
90
94
  return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
95
+ if (noteAudioMime(fileName) && size > 4_000_000) {
96
+ return { ok: false, refusal: "file_too_big", detail: "file_too_large: audio limit is 4 MB (4,000,000 bytes)" };
97
+ }
91
98
  if (size > NOTE_FILE_MAX_BYTES) {
92
99
  return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
93
100
  }