@bli-cockpit/cli 0.2.28 → 0.2.30

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 (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. package/package.json +2 -2
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Signing this machine in: which email to pair as, the email-OTP exchange that
3
+ * gets a short-lived access token, and pairing itself with a manual-approval
4
+ * fallback when the authenticated route is refused.
5
+ *
6
+ * Every failure here degrades to manual dashboard approval rather than
7
+ * stopping — a person who cannot receive the code must still be able to
8
+ * onboard. Split out of commands/local.ts (BLI-3104); moved verbatim.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { errorMessage, isInteractiveStdin, readLine, writeLine, yesByDefault, } from "./cli-io.js";
12
+ import { normalizeUrl } from "./local-args.js";
13
+ import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
14
+ const OTP_CODE_PROMPT = [
15
+ "Email code needed:",
16
+ " Check your latest Cockpit email for a 6- to 10-digit code.",
17
+ "What you can do:",
18
+ " 1) Paste the code here.",
19
+ " 2) No code yet: wait for the resend window, then rerun this command.",
20
+ " 3) Can't use email: rerun with --no-auth for manual approval.",
21
+ "Code: ",
22
+ ].join("\n");
23
+ const OTP_INVALID_MESSAGE = [
24
+ "Email code was not accepted:",
25
+ " Cockpit expects digits only, length 6 to 10.",
26
+ "What you can do:",
27
+ " 1) Rerun and paste the latest email code.",
28
+ " 2) Use manual approval: rerun this command with --no-auth --email <you@buildlaunchiterate.ca>",
29
+ ].join("\n");
30
+ /**
31
+ * Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
32
+ * `--email`. Only called when stdin is a TTY and not in --json mode. An empty
33
+ * answer or a non-email skips rather than failing, matching `optionalEmail`'s
34
+ * leniency (the approving admin's account then owns the device).
35
+ */
36
+ export async function promptOnboardEmail(io) {
37
+ const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
38
+ const answer = raw.trim().toLowerCase();
39
+ if (!answer)
40
+ return undefined;
41
+ if (!answer.includes("@")) {
42
+ writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
43
+ return undefined;
44
+ }
45
+ return answer;
46
+ }
47
+ export async function resolveOnboardEmail(command, roots, config, io) {
48
+ if (command.claimedOwnerEmail)
49
+ return command.claimedOwnerEmail;
50
+ const inferred = await inferOnboardEmail(command.homeDir, roots, config);
51
+ const interactive = !command.json && isInteractiveStdin(io);
52
+ if (!inferred) {
53
+ return interactive ? promptOnboardEmail(io) : undefined;
54
+ }
55
+ if (inferred.source === "session") {
56
+ if (!command.json)
57
+ writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
58
+ return inferred.email;
59
+ }
60
+ if (!interactive)
61
+ return inferred.email;
62
+ const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
63
+ const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
64
+ if (typedEmail)
65
+ return typedEmail;
66
+ if (yesByDefault(answer)) {
67
+ return inferred.email;
68
+ }
69
+ return promptOnboardEmail(io);
70
+ }
71
+ export async function resolveInteractiveLoginEmail(command, io) {
72
+ if (command.claimedOwnerEmail)
73
+ return command.claimedOwnerEmail;
74
+ if (command.noAuth || command.json || !isInteractiveStdin(io)) {
75
+ return undefined;
76
+ }
77
+ return promptOnboardEmail(io);
78
+ }
79
+ export async function requestPairingAccessToken(input, io) {
80
+ return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
81
+ }
82
+ export async function requestPairingAccessTokenDetailed(input, io) {
83
+ if (!input.email ||
84
+ input.noAuth ||
85
+ input.json ||
86
+ !isInteractiveStdin(io)) {
87
+ return {
88
+ status: "skipped",
89
+ errorCode: authSkippedReason(input, io),
90
+ };
91
+ }
92
+ try {
93
+ const fetchImpl = io.fetch;
94
+ writeLine(io.stdout, `Signing in as ${input.email}.`);
95
+ const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
96
+ const resendAfter = typeof start.resend_after_seconds === "number"
97
+ ? start.resend_after_seconds
98
+ : 60;
99
+ writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
100
+ const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
101
+ if (!/^\d{6,10}$/.test(code)) {
102
+ throw new Error(OTP_INVALID_MESSAGE);
103
+ }
104
+ const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
105
+ if (typeof verified.access_token !== "string" || !verified.access_token) {
106
+ throw new Error("OTP verified but dashboard returned no access token.");
107
+ }
108
+ writeLine(io.stdout, `Signed in as ${input.email}.`);
109
+ return { status: "ok", accessToken: verified.access_token };
110
+ }
111
+ catch (error) {
112
+ writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
113
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
114
+ return { status: "fail", errorCode: classifyAuthError(error) };
115
+ }
116
+ }
117
+ function authSkippedReason(input, io) {
118
+ if (!input.email)
119
+ return "email_missing";
120
+ if (input.noAuth)
121
+ return "no_auth";
122
+ if (input.json)
123
+ return "json_mode";
124
+ if (!isInteractiveStdin(io))
125
+ return "non_interactive";
126
+ return "auth_skipped";
127
+ }
128
+ function classifyAuthError(error) {
129
+ const message = errorMessage(error);
130
+ if (/\b(otp|code|digit|invalid)\b/i.test(message))
131
+ return "otp_invalid";
132
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
133
+ return "network_or_auth";
134
+ }
135
+ return "auth_failed";
136
+ }
137
+ export async function pairLocalCollectorWithAuthFallback(options, io) {
138
+ try {
139
+ return await pairLocalCollector(options);
140
+ }
141
+ catch (error) {
142
+ if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
143
+ throw error;
144
+ }
145
+ writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
146
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
147
+ return pairLocalCollector({
148
+ ...options,
149
+ pairingAccessToken: undefined,
150
+ });
151
+ }
152
+ }
153
+ function isPairingAuthFailure(error) {
154
+ return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
155
+ }
156
+ async function postOtpStart(fetchImpl, dashboardUrl, email) {
157
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({ email }),
161
+ });
162
+ const parsed = await readJsonResponse(response);
163
+ if (!response.ok) {
164
+ throw new Error(responseErrorMessage(parsed, "OTP start failed"));
165
+ }
166
+ return parsed;
167
+ }
168
+ async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
169
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({ email, code }),
173
+ });
174
+ const parsed = await readJsonResponse(response);
175
+ if (!response.ok) {
176
+ throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
177
+ }
178
+ return parsed;
179
+ }
180
+ async function readJsonResponse(response) {
181
+ const text = await response.text();
182
+ if (!text)
183
+ return null;
184
+ try {
185
+ return JSON.parse(text);
186
+ }
187
+ catch {
188
+ return text;
189
+ }
190
+ }
191
+ function responseErrorMessage(parsed, fallback) {
192
+ if (parsed && typeof parsed === "object") {
193
+ const record = parsed;
194
+ if (typeof record["message"] === "string" && record["message"].trim()) {
195
+ return record["message"];
196
+ }
197
+ if (typeof record["error"] === "string" && record["error"].trim()) {
198
+ return record["error"];
199
+ }
200
+ }
201
+ return fallback;
202
+ }
203
+ /** Best guess at who this machine belongs to: paired session, then saved config, then git. */
204
+ async function inferOnboardEmail(homeDir, roots, config) {
205
+ const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
206
+ const sessionEmail = normalizeEmailForComparison(session?.email);
207
+ if (session?.session_state === "valid" && sessionEmail) {
208
+ return { email: sessionEmail, source: "session" };
209
+ }
210
+ const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
211
+ if (configEmail)
212
+ return { email: configEmail, source: "config" };
213
+ const gitEmail = await inferUniqueGitEmail(roots);
214
+ return gitEmail ? { email: gitEmail, source: "git" } : null;
215
+ }
216
+ /** Two roots disagreeing about `user.email` is no answer at all, so neither is used. */
217
+ async function inferUniqueGitEmail(roots) {
218
+ const emails = new Set();
219
+ for (const root of roots) {
220
+ const email = await readGitConfigEmail(root);
221
+ if (email)
222
+ emails.add(email);
223
+ }
224
+ return emails.size === 1 ? [...emails][0] ?? null : null;
225
+ }
226
+ async function readGitConfigEmail(root) {
227
+ return new Promise((resolve) => {
228
+ execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
229
+ if (error) {
230
+ resolve(null);
231
+ return;
232
+ }
233
+ const email = normalizeEmailForComparison(stdout);
234
+ resolve(email?.includes("@") ? email : null);
235
+ });
236
+ });
237
+ }
238
+ /** Reuse the paired session only when it is the same person on the same dashboard. */
239
+ export function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
240
+ if (session.session_state !== "valid")
241
+ return false;
242
+ const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
243
+ if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
244
+ return false;
245
+ }
246
+ const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
247
+ if (expectedDashboardUrl &&
248
+ normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+ function normalizeEmailForComparison(value) {
254
+ const normalized = value?.trim().toLowerCase();
255
+ return normalized ? normalized : null;
256
+ }
257
+ export async function readOnboardSessionReuseCandidate(homeDir) {
258
+ const paths = getCollectorRuntimePaths(homeDir);
259
+ try {
260
+ return await readLocalCollectorSessionFile(paths);
261
+ }
262
+ catch {
263
+ return readLocalSessionReference(paths);
264
+ }
265
+ }
266
+ function normalizeUrlForComparison(value) {
267
+ return value ? normalizeUrl(value) : null;
268
+ }
@@ -0,0 +1,100 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
3
+ import { discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
4
+ export async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
5
+ // What the operator typed this run, else what they typed some previous run,
6
+ // else the built-in defaults (BLI-2362).
7
+ const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
8
+ const maxWorktrees = limits.maxRepos;
9
+ const roots = Array.isArray(repoRoot)
10
+ ? repoRoot
11
+ : [repoRoot ?? process.cwd()];
12
+ const result = await discoverGitWorktreesInRootsWithStatus(roots, {
13
+ maxDepth: limits.maxDepth,
14
+ maxWorktrees,
15
+ });
16
+ if (io && result.unreadable_dirs.length > 0) {
17
+ // Never silently dropped: anything under these folders is missing from the
18
+ // scan, so say so even when the run otherwise succeeds.
19
+ writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
20
+ }
21
+ const worktrees = result.worktrees;
22
+ if (!result.complete) {
23
+ // Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
24
+ // cursor after a partial scan would mark the run as covering repos it
25
+ // never saw, permanently skipping their sessions — backfill can tolerate
26
+ // partial only because it keeps per-scope completion markers, and sync
27
+ // does not. The bug (BLI-2362) was that the refusal named no roots and
28
+ // gave no runnable command, so a big workspace just stayed red forever.
29
+ const message = incompleteDiscoveryMessage({
30
+ result,
31
+ roots,
32
+ maxDepth: limits.maxDepth,
33
+ maxRepos: maxWorktrees,
34
+ found: worktrees.length,
35
+ });
36
+ if (io)
37
+ writeLine(io.stderr, message);
38
+ throw new Error(message);
39
+ }
40
+ if (worktrees.length === 0 && !discovery.allowEmpty) {
41
+ throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
42
+ }
43
+ return worktrees;
44
+ }
45
+ /**
46
+ * Persists `--max-depth` / `--max-repos` when a command carried them, so the
47
+ * number survives into the background sync and the doctor's own sync — neither
48
+ * of which has anywhere to type one (BLI-2362). Best-effort: failing to record
49
+ * a preference must never fail the command the operator actually asked for.
50
+ */
51
+ export async function rememberDiscoveryLimits(command) {
52
+ const limits = command;
53
+ if (limits.maxDepth === undefined && limits.maxRepos === undefined)
54
+ return;
55
+ await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
56
+ }
57
+ /**
58
+ * Says which folders could not be opened, and therefore what the scan could not
59
+ * see. Reported without failing the run — an unreadable folder cannot be fixed
60
+ * by retrying, so blocking on one would strand the machine (BLI-2362).
61
+ */
62
+ function unreadableDirectoriesMessage(unreadable) {
63
+ return [
64
+ `WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
65
+ ...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
66
+ "Collection continued for everything else. If a repo is missing from Cockpit,",
67
+ "check the permissions on the folders above.",
68
+ ].join("\n");
69
+ }
70
+ /**
71
+ * Names the roots that could not be covered and hands back a command that
72
+ * actually fixes it, with this machine's numbers already filled in.
73
+ */
74
+ function incompleteDiscoveryMessage(input) {
75
+ const { result, roots, maxDepth, maxRepos, found } = input;
76
+ const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
77
+ const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
78
+ const nextDepth = maxDepth + 3;
79
+ const nextRepos = Math.max(maxRepos * 2, found + 50);
80
+ const retry = [
81
+ "cockpit do-everything",
82
+ ...roots.map((root) => `--workspace ${root}`),
83
+ `--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
84
+ `--max-repos ${nextRepos}`,
85
+ ].join(" ");
86
+ return [
87
+ `Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
88
+ "It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
89
+ "",
90
+ "Could not fully scan:",
91
+ ...blocked.map((root) => ` ${root}`),
92
+ "",
93
+ `Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
94
+ "",
95
+ "Run this to raise the limits and try again:",
96
+ ` ${retry}`,
97
+ "",
98
+ "If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
99
+ ].join("\n");
100
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * What `cockpit --help` and `cockpit <command> --help` print, plus the set of
3
+ * command names the top-level router recognises.
4
+ *
5
+ * Pure text and one lookup table — no filesystem, no network, no process. Split
6
+ * out of commands/local.ts (BLI-3104) with every string moved verbatim: help
7
+ * output is what an intern pastes back when something breaks, so it is a
8
+ * user-visible contract like any other.
9
+ */
10
+ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
11
+ export const rootCommandNames = new Set([
12
+ "onboard",
13
+ "update",
14
+ "upgrade",
15
+ "do-everything",
16
+ "fix",
17
+ "install",
18
+ "login",
19
+ "pair",
20
+ "logout",
21
+ "start",
22
+ "sync",
23
+ "analyze",
24
+ "backfill",
25
+ "status",
26
+ "sessions",
27
+ "serve",
28
+ "autostart",
29
+ "agent-rules",
30
+ "release",
31
+ ]);
32
+ export function localCommandHelp(command) {
33
+ if (command)
34
+ return localSubcommandHelp(command);
35
+ return [
36
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
37
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
38
+ " cockpit upgrade [same flags as update]",
39
+ " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
40
+ " cockpit fix [same flags as do-everything]",
41
+ " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
42
+ " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
43
+ " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
44
+ " cockpit logout",
45
+ " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
46
+ " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
47
+ " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
48
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
49
+ " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
50
+ " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
51
+ " cockpit serve [--port <port>] [--workspace <path>]",
52
+ " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
53
+ " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
54
+ " cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
55
+ "",
56
+ `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.`,
57
+ ].join("\n");
58
+ }
59
+ function localSubcommandHelp(command) {
60
+ const helpByCommand = new Map([
61
+ [
62
+ "onboard",
63
+ [
64
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
65
+ "",
66
+ "Installs, pairs, starts work context(s), syncs once, completes all-history",
67
+ "Codex and Claude backfill for the saved roots, and then tells you it is working.",
68
+ "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
69
+ "`--repo <path>` remains supported as a backward-compatible alias.",
70
+ `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
71
+ "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
72
+ "Run with no flags in a terminal and it prompts for the dashboard email and OTP code; pass --email to skip the email prompt. Use --no-auth to force the manual approval fallback.",
73
+ "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
74
+ ],
75
+ ],
76
+ [
77
+ "install",
78
+ [
79
+ "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
80
+ "",
81
+ "Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
82
+ "`--repo <path>` remains supported as a backward-compatible alias.",
83
+ "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
84
+ ],
85
+ ],
86
+ [
87
+ "update",
88
+ [
89
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
90
+ "",
91
+ "Updates the global public CLI from npm, then reruns `cockpit onboard`",
92
+ "with the same setup flags so pairing, saved roots, all-history backfill,",
93
+ "agent rules, autostart, and the initial sync are refreshed in one command.",
94
+ "`cockpit upgrade` is an alias.",
95
+ ],
96
+ ],
97
+ [
98
+ "upgrade",
99
+ [
100
+ "Usage: cockpit upgrade [same flags as cockpit update]",
101
+ "",
102
+ "Alias for `cockpit update`.",
103
+ ],
104
+ ],
105
+ [
106
+ "do-everything",
107
+ [
108
+ "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
109
+ "",
110
+ "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.",
111
+ "`cockpit fix` is an alias.",
112
+ "Maintainers only: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
113
+ "--dry-run shows what it would do without changing anything.",
114
+ ],
115
+ ],
116
+ [
117
+ "fix",
118
+ [
119
+ "Usage: cockpit fix [same flags as cockpit do-everything]",
120
+ "",
121
+ "Alias for `cockpit do-everything`.",
122
+ ],
123
+ ],
124
+ [
125
+ "login",
126
+ [
127
+ "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
128
+ "",
129
+ "Signs in with an email OTP when interactive, starts dashboard device pairing, and stores the approved local session.",
130
+ "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
131
+ ],
132
+ ],
133
+ [
134
+ "pair",
135
+ [
136
+ "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
137
+ "",
138
+ "Alias for `cockpit login`.",
139
+ ],
140
+ ],
141
+ ["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
142
+ [
143
+ "start",
144
+ [
145
+ "Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
146
+ "",
147
+ "Starts collecting your work in the background. If you point it at a parent folder it covers every repo inside.",
148
+ "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
149
+ "Use --clear-ticket to go back to collecting general work with no ticket attached.",
150
+ "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
151
+ "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
152
+ "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
153
+ "`--repo <path>` remains supported as a backward-compatible alias; use --workspace in agent guidance.",
154
+ ],
155
+ ],
156
+ [
157
+ "sync",
158
+ [
159
+ "Usage: cockpit sync [--workspace <path>] [--dashboard-url <url>] [--json]",
160
+ "",
161
+ "Uploads your latest collected work. If it cannot reach Cockpit it saves a retry and tries again later.",
162
+ "Omit --dashboard-url for normal production sync; pass it only for staging/custom dashboards or forced re-pairing.",
163
+ "`--repo <path>` remains supported as a backward-compatible alias.",
164
+ "Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
165
+ "transcripts (and Claude subagent sidecars) are attributed to repos",
166
+ "deterministically and ambiguous transcripts are retained as unattributed",
167
+ "instead of being duplicated across repos. Use `cockpit sessions` to see why",
168
+ "a session is or is not collected.",
169
+ "New repos start being collected automatically, with no ticket attached.",
170
+ "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
171
+ "--max-depth and --max-repos.",
172
+ "Also self-updates the CLI from npm latest once per day, strictly after",
173
+ "collection finishes; set COCKPIT_DISABLE_AUTO_UPDATE=1 to freeze the",
174
+ "installed version during incident triage.",
175
+ ],
176
+ ],
177
+ [
178
+ "analyze",
179
+ [
180
+ "Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
181
+ "",
182
+ "Uploads your latest collected work, then asks Cockpit to analyse it.",
183
+ "The command returns after the batch is queued; view status and results in My Work.",
184
+ "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
185
+ "`--repo <path>` remains supported as a backward-compatible alias.",
186
+ ],
187
+ ],
188
+ [
189
+ "backfill",
190
+ [
191
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
192
+ "",
193
+ "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
194
+ "Omit --source to scan both sources; there is no --source all literal.",
195
+ "The non-all window is capped at the collector session paired_at timestamp.",
196
+ "--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
197
+ "Repo discovery scans 3 folder levels and up to 50 repos by default.",
198
+ "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
199
+ "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
200
+ ],
201
+ ],
202
+ [
203
+ "status",
204
+ [
205
+ "Usage: cockpit status [--workspace <path>] [--json]",
206
+ "",
207
+ "Prints install, pairing, active work, upload, and retry state.",
208
+ "`--repo <path>` remains supported as a backward-compatible alias.",
209
+ ],
210
+ ],
211
+ [
212
+ "sessions",
213
+ [
214
+ "Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
215
+ "",
216
+ "Read-only: re-runs Codex + Claude session attribution and prints each",
217
+ "session's id, source, state, reason, scores, signals, and per-sidecar",
218
+ "skip reasons. No upload, no cursor writes. Answers \"why is session X",
219
+ "missing?\" locally — counts and labels only, never paths or content.",
220
+ "--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
221
+ "`--repo <path>` remains supported as a backward-compatible alias.",
222
+ ],
223
+ ],
224
+ [
225
+ "serve",
226
+ [
227
+ "Usage: cockpit serve [--port <port>] [--workspace <path>]",
228
+ "",
229
+ "Starts the local collector HTTP status server.",
230
+ "`--repo <path>` remains supported as a backward-compatible alias.",
231
+ ],
232
+ ],
233
+ [
234
+ "autostart",
235
+ [
236
+ "Usage: cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
237
+ "",
238
+ "Installs background sync through launchd on Apple Silicon macOS or a",
239
+ "per-user Task Scheduler task on native Windows.",
240
+ "Runs `cockpit sync` every 15 min by default and survives reboots;",
241
+ "the Windows task runs while the user is signed in.",
242
+ "Action defaults to `install`. `--workspace` is the parent work folder to sync.",
243
+ "`--repo <path>` remains supported as a backward-compatible alias.",
244
+ "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
245
+ "See docs/runbooks/cockpit-launchd-sync.md and",
246
+ "docs/runbooks/cockpit-windows-task-scheduler-sync.md.",
247
+ ],
248
+ ],
249
+ [
250
+ "agent-rules",
251
+ [
252
+ "Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
253
+ "",
254
+ "Installs a managed Cockpit Ticket Binding block into ~/.codex/AGENTS.md",
255
+ "and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
256
+ "The block is scoped to --workspace, or the current directory when omitted,",
257
+ "so Codex and Claude only run Cockpit ticket binding inside that onboarded folder.",
258
+ "Action defaults to `install`.",
259
+ ],
260
+ ],
261
+ [
262
+ "release",
263
+ [
264
+ "Usage: cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
265
+ "",
266
+ "Maintainer-only helper. Run from inside the bli-cockpit repo checkout.",
267
+ "Requires a clean `main` branch and runs `git pull --ff-only` before publishing.",
268
+ "Delegates to `npm run publish:public -- ...` so public packages are",
269
+ "built, checked, and published in the safe telemetry-core then CLI order.",
270
+ ],
271
+ ],
272
+ ]);
273
+ return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
274
+ }
275
+ /** `cockpit <command> --help` — the command name alone, with only the help flag. */
276
+ export function isLocalHelpRequest(argv) {
277
+ const command = argv[0];
278
+ if (!command || !rootCommandNames.has(command))
279
+ return false;
280
+ return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
281
+ }