@bli-cockpit/cli 0.1.23 → 0.1.24

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.
package/README.md CHANGED
@@ -11,27 +11,35 @@ device pairing.
11
11
 
12
12
  ## One-paste install
13
13
 
14
- Run this from the repo where work will happen:
14
+ Run this in a terminal. If you are inside `~/BLI`, Cockpit suggests that
15
+ workspace root; otherwise it looks for likely roots such as `~/BLI` and asks
16
+ before collecting. Later reruns remember the approved roots:
15
17
 
16
18
  ```bash
17
19
  npm install -g @bli-cockpit/cli@latest
18
- cockpit onboard --workspace "$PWD"
20
+ cockpit onboard
19
21
  ```
20
22
 
21
23
  Interactive onboarding prompts for the dashboard email. Enter the approved
22
24
  intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
23
- pass the email explicitly:
25
+ pass the email and root explicitly:
24
26
 
25
27
  ```bash
26
- cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"
28
+ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
27
29
  ```
28
30
 
29
- If a work folder contains multiple repos, run the same command from the parent:
31
+ If a work folder contains multiple repos, confirm the parent folder as the
32
+ collection root. In a headless setup, pass it explicitly:
30
33
 
31
34
  ```bash
32
- cd ~/Downloads/bluepearl-workspace
33
35
  npm install -g @bli-cockpit/cli@latest
34
- cockpit onboard --workspace "$PWD"
36
+ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
37
+ ```
38
+
39
+ For multiple unrelated collection roots, repeat `--workspace`:
40
+
41
+ ```bash
42
+ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-projects
35
43
  ```
36
44
 
37
45
  The CLI defaults to the production dashboard. Normal intern/operator setup,
@@ -39,25 +47,23 @@ updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
39
47
  staging, a custom dashboard, or deliberately forcing a different dashboard
40
48
  pairing. Already-onboarded users update with
41
49
  `npm install -g @bli-cockpit/cli@latest`, then run
42
- `cockpit sync --workspace "$PWD" --json`. `--repo <path>` remains supported
43
- for older prompts and the agent ticket-binding guardrail.
44
-
45
- On machines where Codex or Claude agents will do ticketed work, interactive
46
- `cockpit onboard` checks `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after
47
- harvest proof. If equivalent Cockpit ticket-binding guidance already exists, it
48
- leaves the files alone. If guidance is missing or clearly stale, it asks whether
49
- to install or replace it. The managed guidance is scoped to the workspace path
50
- used for onboarding, so agents should ignore it in private chats or unrelated
51
- repos.
52
-
53
- For intern machines, accept the onboarding prompt to keep Cockpit syncing in the
54
- background. It installs the launchd autostart agent so Cockpit syncs at login
55
- and every 15 minutes. If skipped, run
56
- `cockpit autostart install --workspace "$PWD"` later.
50
+ `cockpit onboard` from anywhere to refresh pairing, agent rules, autostart, and
51
+ an initial sync against saved roots. `--repo <path>` remains supported for older
52
+ prompts and the agent ticket-binding guardrail.
53
+
54
+ On machines where Codex or Claude agents will do ticketed work, `cockpit
55
+ onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
56
+ proof. The managed Cockpit ticket-binding block is replaced in place rather
57
+ than duplicated and is scoped to the confirmed root set, so agents should ignore
58
+ it in private chats or unrelated repos.
59
+
60
+ For intern machines, `cockpit onboard` refreshes the launchd autostart agent so
61
+ Cockpit syncs confirmed roots at login and every 15 minutes. For a manual
62
+ repair path, run `cockpit autostart install --workspace <root>` later.
57
63
 
58
64
  ```bash
59
- # Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
60
- cockpit agent-rules install --workspace "$PWD"
65
+ # Repair/manual path.
66
+ cockpit agent-rules install --workspace <root>
61
67
  ```
62
68
 
63
69
  The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
@@ -87,18 +93,21 @@ What happens:
87
93
  4. Admin approves the persistent email access request from Ambient -> Collector
88
94
  approvals. Pair codes still expire, but the access request remains. If the
89
95
  terminal is still polling, approval pairs it immediately; if not, the intern
90
- reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"` and the
91
- stored approval auto-pairs without another Admin action. Admin can still
92
- paste the printed code there as a rescue path. The signed-in intern can still
93
- use the printed URL. If the claimed email is not an app user yet, a super
94
- admin approval creates the user, adds team membership, and sends the
95
- Supabase invite flow automatically.
96
+ reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI` or
97
+ simply `cockpit onboard` after roots are saved, and the stored approval
98
+ auto-pairs without another Admin action. Admin can still paste the printed
99
+ code there as a rescue path. The signed-in intern can still use the printed
100
+ URL. If the claimed email is not an app user yet, a super admin approval
101
+ creates the user, adds team membership, and sends the Supabase invite flow
102
+ automatically.
96
103
  5. The CLI starts general ambient capture, uploads private raw evidence objects
97
104
  when present (chunked and resumable, with identical content acknowledged
98
105
  instead of re-uploaded), then uploads one safe metadata/ref envelope. Text
99
106
  evidence with secret-shaped values is sanitized before storage and marked as
100
107
  partial evidence with redaction metadata; env files are still never read.
101
- 6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
108
+ 6. The CLI prints `PASS: Cockpit collector is ready for harvest.` and a final
109
+ `You're live.` block with collection roots, pairing state, background sync
110
+ state, dashboard link, and `cockpit status` as the next command.
102
111
 
103
112
  `--device-name` is optional and only a readable label in Cockpit. Pass it only
104
113
  when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
@@ -15,7 +15,11 @@ export async function installAgentRules(options = {}) {
15
15
  }
16
16
  async function installAgentRulesForHost(host, options = {}) {
17
17
  const rulesFile = agentRulesFile(host, options.homeDir);
18
- const block = cockpitAgentRulesBlock({ scopePath: options.scopePath });
18
+ const block = cockpitAgentRulesBlock({
19
+ scopePath: options.scopePath,
20
+ scopePaths: options.scopePaths,
21
+ });
22
+ const scopePaths = normalizeScopePaths(options);
19
23
  let existing = "";
20
24
  let existed = true;
21
25
  try {
@@ -24,7 +28,7 @@ async function installAgentRulesForHost(host, options = {}) {
24
28
  catch {
25
29
  existed = false;
26
30
  }
27
- const prepared = prepareManagedBlockInstall(existing, block, options.scopePath);
31
+ const prepared = prepareManagedBlockInstall(existing, block, scopePaths);
28
32
  if (!prepared.next) {
29
33
  return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
34
  }
@@ -78,7 +82,11 @@ export async function inspectAgentRules(options = {}) {
78
82
  }
79
83
  async function inspectAgentRulesForHost(host, options = {}) {
80
84
  const rulesFile = agentRulesFile(host, options.homeDir);
81
- const block = cockpitAgentRulesBlock({ scopePath: options.scopePath });
85
+ const block = cockpitAgentRulesBlock({
86
+ scopePath: options.scopePath,
87
+ scopePaths: options.scopePaths,
88
+ });
89
+ const scopePaths = normalizeScopePaths(options);
82
90
  let existing = "";
83
91
  try {
84
92
  existing = await readFile(rulesFile, "utf8");
@@ -89,7 +97,7 @@ async function inspectAgentRulesForHost(host, options = {}) {
89
97
  installed: false,
90
98
  };
91
99
  }
92
- const state = inspectAgentRulesContents(existing, block, options.scopePath);
100
+ const state = inspectAgentRulesContents(existing, block, scopePaths);
93
101
  const installed = state === "managed" || state === "equivalent";
94
102
  return {
95
103
  ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
@@ -97,10 +105,12 @@ async function inspectAgentRulesForHost(host, options = {}) {
97
105
  };
98
106
  }
99
107
  export function cockpitAgentRulesBlock(options = {}) {
100
- const scopePath = options.scopePath ? path.resolve(options.scopePath) : null;
101
- const scopeLine = scopePath
102
- ? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${scopePath}\`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
103
- : "- This guidance only applies when the current working directory is inside the workspace/repo that ran `cockpit onboard` or `cockpit agent-rules install`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.";
108
+ const scopePaths = normalizeScopePaths(options);
109
+ const scopeLine = scopePaths.length === 1
110
+ ? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
111
+ : scopePaths.length > 1
112
+ ? `- This guidance only applies when the current working directory is inside one of these Cockpit-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
113
+ : "- This guidance only applies when the current working directory is inside the workspace/repo that ran `cockpit onboard` or `cockpit agent-rules install`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.";
104
114
  return [
105
115
  MANAGED_BLOCK_START,
106
116
  "## Cockpit Ticket Binding",
@@ -114,6 +124,19 @@ export function cockpitAgentRulesBlock(options = {}) {
114
124
  MANAGED_BLOCK_END,
115
125
  ].join("\n");
116
126
  }
127
+ function normalizeScopePaths(options) {
128
+ const raw = options.scopePaths ?? (options.scopePath ? [options.scopePath] : []);
129
+ const seen = new Set();
130
+ const normalized = [];
131
+ for (const scopePath of raw) {
132
+ const resolved = path.resolve(scopePath);
133
+ if (seen.has(resolved))
134
+ continue;
135
+ seen.add(resolved);
136
+ normalized.push(resolved);
137
+ }
138
+ return normalized;
139
+ }
117
140
  export function hasManagedBlock(contents) {
118
141
  return contents.includes(MANAGED_BLOCK_START) && contents.includes(MANAGED_BLOCK_END);
119
142
  }
@@ -131,7 +154,7 @@ export function removeManagedBlock(contents) {
131
154
  return contents;
132
155
  return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
133
156
  }
134
- function prepareManagedBlockInstall(contents, block, scopePath) {
157
+ function prepareManagedBlockInstall(contents, block, scopePaths = []) {
135
158
  if (!contents.trim())
136
159
  return { next: `${block}\n`, state: "missing" };
137
160
  if (hasManagedBlock(contents)) {
@@ -140,10 +163,10 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
140
163
  state: extractManagedBlock(contents) === block ? "managed" : "stale",
141
164
  };
142
165
  }
143
- if (hasEquivalentUnmanagedTicketBinding(contents, scopePath)) {
166
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths)) {
144
167
  return { next: null, state: "equivalent" };
145
168
  }
146
- const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePath);
169
+ const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePaths);
147
170
  if (staleBlock) {
148
171
  return {
149
172
  next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
@@ -152,13 +175,13 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
152
175
  }
153
176
  return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
154
177
  }
155
- function inspectAgentRulesContents(contents, block, scopePath) {
178
+ function inspectAgentRulesContents(contents, block, scopePaths = []) {
156
179
  if (hasManagedBlock(contents)) {
157
180
  return extractManagedBlock(contents) === block ? "managed" : "stale";
158
181
  }
159
- if (hasEquivalentUnmanagedTicketBinding(contents, scopePath))
182
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths))
160
183
  return "equivalent";
161
- if (findStaleUnmanagedTicketBindingBlock(contents, scopePath))
184
+ if (findStaleUnmanagedTicketBindingBlock(contents, scopePaths))
162
185
  return "stale";
163
186
  return "missing";
164
187
  }
@@ -200,13 +223,13 @@ function managedBlockPattern() {
200
223
  function extractManagedBlock(contents) {
201
224
  return contents.match(managedBlockPattern())?.[0] ?? null;
202
225
  }
203
- function hasEquivalentUnmanagedTicketBinding(contents, scopePath) {
226
+ function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
204
227
  const text = normalizeRuleText(contents);
205
228
  if (!hasTicketBindingCues(text))
206
229
  return false;
207
230
  if (!hasRepoScopeGuard(text))
208
231
  return false;
209
- if (scopePath && !hasScopePath(text, scopePath))
232
+ if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
210
233
  return false;
211
234
  if (!hasTicketLookupOrCreationCue(text))
212
235
  return false;
@@ -231,7 +254,7 @@ function hasRepoScopeGuard(text) {
231
254
  /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
232
255
  /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
233
256
  }
234
- function findStaleUnmanagedTicketBindingBlock(contents, scopePath) {
257
+ function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
235
258
  const lines = contents.split("\n");
236
259
  for (let index = 0; index < lines.length; index += 1) {
237
260
  if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
@@ -247,7 +270,7 @@ function findStaleUnmanagedTicketBindingBlock(contents, scopePath) {
247
270
  const candidate = lines.slice(index, endLine).join("\n");
248
271
  const normalized = normalizeRuleText(candidate);
249
272
  if (hasTicketBindingCues(normalized) &&
250
- !hasEquivalentUnmanagedTicketBinding(candidate, scopePath)) {
273
+ !hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
251
274
  return { startLine: index, endLine };
252
275
  }
253
276
  }
package/dist/autostart.js CHANGED
@@ -29,7 +29,9 @@ export async function installAutostartAgent(options) {
29
29
  if (platform !== "darwin")
30
30
  return unsupportedResult(options.homeDir);
31
31
  const homeDir = options.homeDir ?? os.homedir();
32
- const workDir = path.resolve(options.repoRoot ?? process.cwd());
32
+ const workDirs = normalizeWorkDirs(options.repoRoots);
33
+ const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
34
+ const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
33
35
  const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
34
36
  const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
35
37
  const paths = getCollectorRuntimePaths(homeDir);
@@ -44,6 +46,7 @@ export async function installAutostartAgent(options) {
44
46
  await mkdir(paths.state_dir, { recursive: true });
45
47
  await writeFile(plistPath, renderPlist({
46
48
  workDir,
49
+ workDirs: resolvedWorkDirs,
47
50
  dashboardUrl,
48
51
  intervalSeconds,
49
52
  stdoutPath,
@@ -62,6 +65,7 @@ export async function installAutostartAgent(options) {
62
65
  loaded,
63
66
  interval_seconds: intervalSeconds,
64
67
  work_dir: workDir,
68
+ work_dirs: resolvedWorkDirs,
65
69
  dashboard_url: dashboardUrl,
66
70
  ...(loaded
67
71
  ? {}
@@ -131,7 +135,10 @@ function renderPlist(options) {
131
135
  const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
132
136
  ? ""
133
137
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
134
- const command = `cockpit sync --workspace ${shellQuote(options.workDir)}${dashboardArg} --json`;
138
+ const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
139
+ const command = roots
140
+ .map((root) => `cockpit sync --workspace ${shellQuote(root)}${dashboardArg} --json`)
141
+ .join("; ");
135
142
  return [
136
143
  '<?xml version="1.0" encoding="UTF-8"?>',
137
144
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -169,6 +176,20 @@ function renderPlist(options) {
169
176
  "",
170
177
  ].join("\n");
171
178
  }
179
+ function normalizeWorkDirs(repoRoots) {
180
+ if (!repoRoots)
181
+ return [];
182
+ const seen = new Set();
183
+ const normalized = [];
184
+ for (const root of repoRoots) {
185
+ const resolved = path.resolve(root);
186
+ if (seen.has(resolved))
187
+ continue;
188
+ seen.add(resolved);
189
+ normalized.push(resolved);
190
+ }
191
+ return normalized;
192
+ }
172
193
  /**
173
194
  * Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
174
195
  * transcript dir exists yet (an empty array would tell launchd to watch
@@ -74,6 +74,7 @@ function parseOnboardArgs(args) {
74
74
  kind: "onboard",
75
75
  homeDir: optionalNonEmpty(values.flags.get("--home")),
76
76
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
77
+ collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
77
78
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
78
79
  claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
79
80
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
@@ -403,6 +404,7 @@ function parseNamedArgs(args, options) {
403
404
  const allowed = new Set(options.allowedFlags);
404
405
  const valueFlags = new Set(options.valueFlags);
405
406
  const flags = new Map();
407
+ const flagValues = new Map();
406
408
  const booleans = new Set();
407
409
  const positionals = [];
408
410
  for (let index = 0; index < args.length; index += 1) {
@@ -422,6 +424,9 @@ function parseNamedArgs(args, options) {
422
424
  }
423
425
  rejectServiceRoleLikeArgument(value);
424
426
  flags.set(flag, value);
427
+ const existing = flagValues.get(flag) ?? [];
428
+ existing.push(value);
429
+ flagValues.set(flag, existing);
425
430
  if (inlineValue === undefined)
426
431
  index += 1;
427
432
  }
@@ -431,7 +436,7 @@ function parseNamedArgs(args, options) {
431
436
  booleans.add(flag);
432
437
  }
433
438
  }
434
- return { flags, booleans, positionals };
439
+ return { flags, flagValues, booleans, positionals };
435
440
  }
436
441
  function workRootFlagValue(values) {
437
442
  const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
@@ -443,6 +448,19 @@ function workRootFlagValue(values) {
443
448
  }
444
449
  return values.flags.get("--workspace") ?? values.flags.get("--repo");
445
450
  }
451
+ function workRootFlagValues(values) {
452
+ const roots = [];
453
+ for (const flag of WORK_ROOT_FLAGS) {
454
+ roots.push(...(values.flagValues.get(flag) ?? []));
455
+ }
456
+ return roots;
457
+ }
458
+ function optionalNonEmptyList(values) {
459
+ const filtered = values
460
+ .map((value) => optionalNonEmpty(value))
461
+ .filter((value) => Boolean(value));
462
+ return filtered.length > 0 ? filtered : undefined;
463
+ }
446
464
  function assertNoPositionals(positionals, command) {
447
465
  if (positionals.length > 0) {
448
466
  throw new Error(`${command} does not accept positional arguments.`);
@@ -4,13 +4,14 @@ import path from "node:path";
4
4
  import { createCollectorServer } from "../server.js";
5
5
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
6
6
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
7
- import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
8
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
7
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
8
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
9
9
  import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
10
10
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
11
11
  import { acquireSyncLock } from "../sync-lock.js";
12
12
  import { discoverGitWorktrees } from "../repo-identity.js";
13
- import { runAttributedWorktreeSync } from "./session-sync.js";
13
+ import { runAttributedWorktreeSync, } from "./session-sync.js";
14
+ import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
14
15
  export const rootCommandNames = new Set([
15
16
  "onboard",
16
17
  "install",
@@ -281,68 +282,112 @@ async function promptOnboardEmail(io) {
281
282
  }
282
283
  return answer;
283
284
  }
285
+ function onboardingRootPrompt(io) {
286
+ return {
287
+ confirm: async (message) => yesByDefault(await readLine(io, message)),
288
+ input: (message) => readLine(io, message),
289
+ };
290
+ }
291
+ function yesByDefault(raw) {
292
+ const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
293
+ return answer !== "n" && answer !== "no";
294
+ }
295
+ async function resolveOnboardEmail(command, roots, config, io) {
296
+ if (command.claimedOwnerEmail)
297
+ return command.claimedOwnerEmail;
298
+ const inferred = await inferOnboardEmail(command.homeDir, roots, config);
299
+ const interactive = !command.json && isInteractiveStdin(io);
300
+ if (!inferred) {
301
+ return interactive ? promptOnboardEmail(io) : undefined;
302
+ }
303
+ if (inferred.source === "session") {
304
+ if (!command.json)
305
+ writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
306
+ return inferred.email;
307
+ }
308
+ if (!interactive)
309
+ return inferred.email;
310
+ const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
311
+ const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
312
+ if (typedEmail)
313
+ return typedEmail;
314
+ if (yesByDefault(answer)) {
315
+ return inferred.email;
316
+ }
317
+ return promptOnboardEmail(io);
318
+ }
319
+ async function inferOnboardEmail(homeDir, roots, config) {
320
+ const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
321
+ const sessionEmail = normalizeEmailForComparison(session?.email);
322
+ if (session?.session_state === "valid" && sessionEmail) {
323
+ return { email: sessionEmail, source: "session" };
324
+ }
325
+ const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
326
+ if (configEmail)
327
+ return { email: configEmail, source: "config" };
328
+ const gitEmail = await inferUniqueGitEmail(roots);
329
+ return gitEmail ? { email: gitEmail, source: "git" } : null;
330
+ }
331
+ async function inferUniqueGitEmail(roots) {
332
+ const emails = new Set();
333
+ for (const root of roots) {
334
+ const email = await readGitConfigEmail(root);
335
+ if (email)
336
+ emails.add(email);
337
+ }
338
+ return emails.size === 1 ? [...emails][0] ?? null : null;
339
+ }
340
+ async function readGitConfigEmail(root) {
341
+ return new Promise((resolve) => {
342
+ execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
343
+ if (error) {
344
+ resolve(null);
345
+ return;
346
+ }
347
+ const email = normalizeEmailForComparison(stdout);
348
+ resolve(email?.includes("@") ? email : null);
349
+ });
350
+ });
351
+ }
284
352
  /**
285
- * After a successful onboard, offers to install the launchd autostart agent so a
286
- * Mac mini keeps syncing without anyone re-running cockpit — this is the fix for
287
- * interns drifting to Stale. Only in an interactive, non-JSON run with a real
288
- * exec runner (`io.exec`): headless / piped / spawned onboards and tests that
289
- * pass no exec skip it entirely. Declining leaves onboarding's success untouched.
353
+ * After a successful onboard, refreshes the launchd autostart agent so intern
354
+ * machines keep syncing without anyone re-running Cockpit. Runtimes without an
355
+ * exec runner skip this because launchd installation cannot be attempted there.
290
356
  */
291
- async function maybeOfferAutostart(command, io) {
292
- if (command.json || !isInteractiveStdin(io) || !io.exec)
293
- return;
294
- const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
295
- .trim()
296
- .toLowerCase();
297
- if (answer === "n" || answer === "no") {
298
- writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
299
- return;
300
- }
357
+ async function refreshOnboardAutostart(command, roots, io) {
358
+ if (!io.exec)
359
+ return null;
301
360
  const result = await installAutostartAgent({
302
361
  homeDir: command.homeDir,
303
- repoRoot: command.repoRoot,
362
+ repoRoot: roots[0],
363
+ repoRoots: roots,
304
364
  dashboardUrl: command.dashboardUrl,
305
365
  exec: io.exec,
306
366
  });
367
+ if (command.json)
368
+ return result;
307
369
  if (result.status === "unsupported") {
308
370
  writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
309
- return;
371
+ return result;
310
372
  }
311
373
  writeLine(io.stdout, result.loaded
312
- ? "Background autostart installed; Cockpit syncs at login and every 15 min."
313
- : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
374
+ ? "Background autostart refreshed; Cockpit syncs at login and every 15 min."
375
+ : "Background autostart refreshed, but launchctl load reported a problem; check `cockpit autostart status`.");
314
376
  writeLine(io.stdout, `Plist: ${result.plist_path}`);
377
+ return result;
315
378
  }
316
- async function maybeOfferAgentRules(command, io) {
317
- if (command.json || !isInteractiveStdin(io))
318
- return;
319
- const scopePath = path.resolve(command.repoRoot ?? process.cwd());
320
- const current = await inspectAgentRules({
379
+ async function refreshOnboardAgentRules(command, roots, io) {
380
+ const result = await installAgentRules({
321
381
  homeDir: command.homeDir,
322
- scopePath,
382
+ scopePaths: roots,
323
383
  });
324
- if (current.installed) {
325
- writeLine(io.stdout, onboardAgentRulesAlreadyInstalledLine(current));
326
- return;
327
- }
328
- const answer = (await readLine(io, `Add Cockpit ticket-binding rules scoped to ${scopePath} to AGENTS.md and CLAUDE.md? [Y/n] `))
329
- .trim()
330
- .toLowerCase();
331
- if (answer === "n" || answer === "no") {
332
- writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install --workspace \"$PWD\"` anytime.");
333
- return;
334
- }
335
- const result = await installAgentRules({ homeDir: command.homeDir, scopePath });
384
+ if (command.json)
385
+ return result;
336
386
  writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
337
387
  for (const target of result.targets) {
338
388
  writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
339
389
  }
340
- }
341
- function onboardAgentRulesAlreadyInstalledLine(result) {
342
- const hasEquivalent = result.targets.some((target) => target.state === "equivalent");
343
- return hasEquivalent
344
- ? "Agent rules: matching Cockpit ticket-binding guidance already exists."
345
- : "Agent rules: already current in AGENTS.md and CLAUDE.md.";
390
+ return result;
346
391
  }
347
392
  function onboardAgentRulesInstallLine(result) {
348
393
  if (result.targets.some((target) => target.stale_block_replaced)) {
@@ -359,24 +404,78 @@ function onboardAgentRulesInstallLine(result) {
359
404
  return "not installed.";
360
405
  }
361
406
  }
407
+ function writeOnboardLiveStatus(io, command, roots, options) {
408
+ writeLine(io.stdout, "You're live.");
409
+ writeLine(io.stdout, "Collecting from:");
410
+ for (const root of roots)
411
+ writeLine(io.stdout, `- ${root}`);
412
+ writeLine(io.stdout, `Paired user: ${options.pair?.session.email ??
413
+ options.pair?.session.auth_subject_id ??
414
+ (options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
415
+ writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
416
+ writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
417
+ writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
418
+ writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
419
+ writeLine(io.stdout, "Next: cockpit status");
420
+ }
421
+ function backgroundSyncLine(result) {
422
+ if (!result)
423
+ return "not refreshed (launchd runner unavailable)";
424
+ if (result.status === "unsupported")
425
+ return `unsupported (${result.message})`;
426
+ if (result.status === "installed" && result.loaded)
427
+ return "installed";
428
+ if (result.status === "installed") {
429
+ return `installed with warning (${result.message ?? "launchctl load failed"})`;
430
+ }
431
+ return result.status;
432
+ }
362
433
  async function runOnboard(command, io) {
363
434
  let install = null;
364
435
  let pair = null;
365
436
  let sync = null;
366
437
  let status = null;
438
+ let rootsResult = null;
439
+ let agentRules = null;
440
+ let autostart = null;
367
441
  try {
368
442
  if (!command.json) {
369
443
  writeLine(io.stdout, "Cockpit harvest onboarding");
370
444
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
371
445
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
372
446
  }
373
- let claimedOwnerEmail = command.claimedOwnerEmail;
374
- if (!claimedOwnerEmail && !command.json && isInteractiveStdin(io)) {
375
- claimedOwnerEmail = await promptOnboardEmail(io);
447
+ const paths = getCollectorRuntimePaths(command.homeDir);
448
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
449
+ const interactive = !command.json && isInteractiveStdin(io);
450
+ rootsResult = await resolveOnboardingRoots({
451
+ homeDir: command.homeDir,
452
+ explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
453
+ config: existingConfig,
454
+ interactive,
455
+ prompt: interactive ? onboardingRootPrompt(io) : undefined,
456
+ });
457
+ const collectionRoots = rootsResult.roots;
458
+ const primaryRoot = collectionRoots[0];
459
+ if (!primaryRoot) {
460
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
461
+ }
462
+ const resolvedCommand = {
463
+ ...command,
464
+ repoRoot: primaryRoot,
465
+ collectionRoots,
466
+ };
467
+ const replaceRepoRoots = rootsResult.source === "prompt" &&
468
+ !command.collectionRoots?.length &&
469
+ (existingConfig?.default_repo_paths.length ?? 0) > 0;
470
+ if (!command.json) {
471
+ writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
376
472
  }
473
+ const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
377
474
  install = await installLocalCollector({
378
475
  homeDir: command.homeDir,
379
- repoRoot: command.repoRoot,
476
+ repoRoot: primaryRoot,
477
+ repoRoots: collectionRoots,
478
+ replaceRepoRoots,
380
479
  dashboardUrl: command.dashboardUrl,
381
480
  deviceName: command.deviceName,
382
481
  });
@@ -386,7 +485,7 @@ async function runOnboard(command, io) {
386
485
  }
387
486
  const installedStatus = await inspectLocalCollectorStatus({
388
487
  homeDir: command.homeDir,
389
- repoRoot: command.repoRoot,
488
+ repoRoot: primaryRoot,
390
489
  branch: command.branch,
391
490
  });
392
491
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
@@ -421,26 +520,41 @@ async function runOnboard(command, io) {
421
520
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
422
521
  }
423
522
  }
424
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
523
+ const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
425
524
  if (worktrees.length > 1) {
426
- const multi = await runMultiRepoOnboard(command, io, worktrees);
525
+ const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
526
+ if (multi.ok) {
527
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
528
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
529
+ }
427
530
  if (command.json) {
428
531
  writeLine(io.stdout, JSON.stringify({
429
- ...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
532
+ ...onboardResult(multi.ok ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
533
+ collection_roots: collectionRoots,
534
+ root_resolution: rootsResult,
535
+ agent_rules: agentRules,
536
+ autostart,
430
537
  mode: "multi_repo",
431
538
  repos: multi.results,
432
539
  codex_sessions: multi.codex_sessions,
433
540
  }, null, 2));
434
541
  }
435
- if (multi.ok) {
436
- await maybeOfferAgentRules(command, io);
437
- await maybeOfferAutostart(command, io);
542
+ if (multi.ok && !command.json) {
543
+ writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
544
+ pair,
545
+ status,
546
+ sync: null,
547
+ agentRules,
548
+ autostart,
549
+ initialSyncOk: true,
550
+ });
438
551
  }
439
552
  return multi.ok ? 0 : 1;
440
553
  }
554
+ const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
441
555
  const context = await startLocalWorkContext({
442
556
  homeDir: command.homeDir,
443
- repoRoot: command.repoRoot,
557
+ repoRoot: worktreeRoot,
444
558
  branch: command.branch,
445
559
  activeTicketId: command.activeTicketId,
446
560
  });
@@ -464,13 +578,15 @@ async function runOnboard(command, io) {
464
578
  }
465
579
  status = await inspectLocalCollectorStatus({
466
580
  homeDir: command.homeDir,
467
- repoRoot: command.repoRoot,
581
+ repoRoot: worktreeRoot,
468
582
  branch: command.branch,
469
583
  });
470
584
  if (sync.status !== "uploaded") {
471
585
  if (command.json) {
472
586
  writeLine(io.stdout, JSON.stringify({
473
- ...onboardResult("blocked", command, install, pair, sync, status),
587
+ ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
588
+ collection_roots: collectionRoots,
589
+ root_resolution: rootsResult,
474
590
  codex_sessions: run.summary,
475
591
  }, null, 2));
476
592
  }
@@ -482,8 +598,14 @@ async function runOnboard(command, io) {
482
598
  return 1;
483
599
  }
484
600
  if (command.json) {
601
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
602
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
485
603
  writeLine(io.stdout, JSON.stringify({
486
- ...onboardResult("pass", command, install, pair, sync, status),
604
+ ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
605
+ collection_roots: collectionRoots,
606
+ root_resolution: rootsResult,
607
+ agent_rules: agentRules,
608
+ autostart,
487
609
  codex_sessions: run.summary,
488
610
  }, null, 2));
489
611
  return 0;
@@ -500,22 +622,35 @@ async function runOnboard(command, io) {
500
622
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
501
623
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
502
624
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
503
- await maybeOfferAgentRules(command, io);
504
- await maybeOfferAutostart(command, io);
625
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
626
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
627
+ writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
628
+ pair,
629
+ status,
630
+ sync,
631
+ agentRules,
632
+ autostart,
633
+ initialSyncOk: true,
634
+ });
505
635
  return 0;
506
636
  }
507
637
  catch (error) {
508
638
  const message = errorMessage(error);
509
- status = await inspectLocalCollectorStatus({
510
- homeDir: command.homeDir,
511
- repoRoot: command.repoRoot,
512
- branch: command.branch,
513
- }).catch(() => null);
639
+ const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
640
+ status = statusRoot
641
+ ? await inspectLocalCollectorStatus({
642
+ homeDir: command.homeDir,
643
+ repoRoot: statusRoot,
644
+ branch: command.branch,
645
+ }).catch(() => null)
646
+ : null;
514
647
  const blocker = classifyOnboardBlocker(message);
515
648
  const nextStep = nextStepForOnboardBlocker(blocker);
516
649
  if (command.json) {
517
650
  writeLine(io.stdout, JSON.stringify({
518
651
  ...onboardResult("blocked", command, install, pair, sync, status),
652
+ collection_roots: rootsResult?.roots ?? [],
653
+ root_resolution: rootsResult,
519
654
  blocker,
520
655
  message,
521
656
  next_step: nextStep,
@@ -625,6 +760,13 @@ function cursorStatusLine(sync) {
625
760
  const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
626
761
  const DEFAULT_DISCOVERY_MAX_REPOS = 50;
627
762
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
763
+ if (Array.isArray(repoRoot)) {
764
+ const discovered = [];
765
+ for (const root of repoRoot) {
766
+ discovered.push(...(await discoverCommandWorktrees(root, discovery, io)));
767
+ }
768
+ return dedupeWorktrees(discovered);
769
+ }
628
770
  const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
629
771
  const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
630
772
  maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
@@ -638,6 +780,18 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
638
780
  }
639
781
  return worktrees;
640
782
  }
783
+ function dedupeWorktrees(worktrees) {
784
+ const seen = new Set();
785
+ const deduped = [];
786
+ for (const worktree of worktrees) {
787
+ const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
788
+ if (seen.has(key))
789
+ continue;
790
+ seen.add(key);
791
+ deduped.push(worktree);
792
+ }
793
+ return deduped;
794
+ }
641
795
  async function runMultiRepoOnboard(command, io, worktrees) {
642
796
  if (!command.json) {
643
797
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
@@ -743,6 +897,7 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
743
897
  work_context_id: sync?.work_context_id ?? status?.work_context_id ?? null,
744
898
  config_file: install?.paths.config_file ?? status?.config_file ?? null,
745
899
  session_file: install?.paths.session_file ?? status?.session_file ?? null,
900
+ collection_roots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
746
901
  paired_user: pair?.session.email ?? pair?.session.auth_subject_id ?? null,
747
902
  paired_device: pair?.session.device_name ?? pair?.session.device_id ?? null,
748
903
  upload_status: sync?.status ?? null,
@@ -756,6 +911,8 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
756
911
  };
757
912
  }
758
913
  function classifyOnboardBlocker(message) {
914
+ if (message.includes(COLLECTION_ROOT_REQUIRED))
915
+ return COLLECTION_ROOT_REQUIRED;
759
916
  if (/ticket/i.test(message))
760
917
  return "ticket";
761
918
  if (/pair|paired|approval|expired|revoked/i.test(message))
@@ -772,6 +929,8 @@ function classifyOnboardBlocker(message) {
772
929
  }
773
930
  function nextStepForOnboardBlocker(blocker) {
774
931
  switch (blocker) {
932
+ case COLLECTION_ROOT_REQUIRED:
933
+ return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
775
934
  case "ticket":
776
935
  case "ticket_binding":
777
936
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
@@ -23,9 +23,10 @@ function cockpitHelp() {
23
23
  localCommandHelp(),
24
24
  "",
25
25
  "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
26
- "Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
27
- "Already onboarded: run `cockpit sync --workspace \"$PWD\" --json`.",
28
- "Agent setup: interactive `cockpit onboard` offers AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace \"$PWD\"` for repair/headless setup.",
26
+ "Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
27
+ "Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
28
+ "Already onboarded: rerun `cockpit onboard` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
+ "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
29
30
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
30
31
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
31
32
  ].join("\n");
@@ -21,12 +21,15 @@ export function getCollectorRuntimePaths(homeDir = os.homedir()) {
21
21
  }
22
22
  export async function installLocalCollector(options = {}) {
23
23
  const homeDir = options.homeDir ?? os.homedir();
24
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
24
+ const repoRoots = normalizeRepoRoots(options.repoRoots);
25
+ const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
25
26
  const paths = getCollectorRuntimePaths(homeDir);
26
27
  await ensureRuntimeDirectories(paths);
27
28
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
28
- const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
29
- defaultRepoPaths.add(repoRoot);
29
+ const defaultRepoPaths = new Set(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? []));
30
+ for (const root of repoRoots.length > 0 ? repoRoots : [repoRoot]) {
31
+ defaultRepoPaths.add(root);
32
+ }
30
33
  const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
31
34
  existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
32
35
  ? "remote_durable_opt_in"
@@ -55,6 +58,20 @@ export async function installLocalCollector(options = {}) {
55
58
  message: "Local collector installed. Pair/login is still required before remote upload.",
56
59
  };
57
60
  }
61
+ function normalizeRepoRoots(repoRoots) {
62
+ if (!repoRoots)
63
+ return [];
64
+ const seen = new Set();
65
+ const normalized = [];
66
+ for (const root of repoRoots) {
67
+ const resolved = path.resolve(root);
68
+ if (seen.has(resolved))
69
+ continue;
70
+ seen.add(resolved);
71
+ normalized.push(resolved);
72
+ }
73
+ return normalized;
74
+ }
58
75
  export async function pairLocalCollector(options = {}) {
59
76
  const homeDir = options.homeDir ?? os.homedir();
60
77
  const paths = getCollectorRuntimePaths(homeDir);
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
5
+ export async function resolveOnboardingRoots(options) {
6
+ const explicitRoots = normalizeRoots(options.explicitRoots ?? []);
7
+ if (explicitRoots.length > 0) {
8
+ return { roots: explicitRoots, source: "explicit", confirmed: true };
9
+ }
10
+ const savedRoots = normalizeRoots([
11
+ ...(options.savedRoots ?? []),
12
+ ...(options.config?.default_repo_paths ?? []),
13
+ ]);
14
+ if (savedRoots.length > 0) {
15
+ if (!options.interactive) {
16
+ return { roots: savedRoots, source: "saved", confirmed: false };
17
+ }
18
+ const confirmed = await requirePrompt(options).confirm(savedRootsPrompt(savedRoots));
19
+ if (confirmed) {
20
+ return { roots: savedRoots, source: "saved", confirmed: true };
21
+ }
22
+ return promptForRoots(options, "Collection root(s), comma-separated: ");
23
+ }
24
+ if (!options.interactive) {
25
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: pass --workspace <path> or run cockpit onboard in a terminal to confirm a collection root.`);
26
+ }
27
+ const cwd = path.resolve(options.cwd ?? process.cwd());
28
+ const cwdRoot = likelyBliRootFromCwd(cwd);
29
+ if (cwdRoot) {
30
+ const confirmed = await requirePrompt(options).confirm(`Collect from ${cwdRoot}? [Y/n] `);
31
+ if (confirmed) {
32
+ return { roots: [cwdRoot], source: "cwd_likely_root", confirmed: true };
33
+ }
34
+ }
35
+ const homeRoot = path.join(options.homeDir ?? os.homedir(), "BLI");
36
+ if (await directoryExists(homeRoot)) {
37
+ const confirmed = await requirePrompt(options).confirm(`I found ${homeRoot}. Collect from there? [Y/n] `);
38
+ if (confirmed) {
39
+ return { roots: [path.resolve(homeRoot)], source: "home_likely_root", confirmed: true };
40
+ }
41
+ }
42
+ return promptForRoots(options, "Collection root(s), comma-separated: ");
43
+ }
44
+ export function normalizeRoots(roots) {
45
+ const seen = new Set();
46
+ const normalized = [];
47
+ for (const root of roots) {
48
+ const trimmed = root.trim();
49
+ if (!trimmed)
50
+ continue;
51
+ for (const candidate of splitRootInput(trimmed)) {
52
+ const resolved = path.resolve(candidate);
53
+ if (resolved === path.parse(resolved).root)
54
+ continue;
55
+ if (resolved === os.homedir())
56
+ continue;
57
+ if (seen.has(resolved))
58
+ continue;
59
+ seen.add(resolved);
60
+ normalized.push(resolved);
61
+ }
62
+ }
63
+ return normalized;
64
+ }
65
+ function splitRootInput(value) {
66
+ return value
67
+ .split(/[,\n]/u)
68
+ .map((part) => part.trim())
69
+ .filter(Boolean);
70
+ }
71
+ async function promptForRoots(options, message) {
72
+ const prompt = requirePrompt(options);
73
+ const roots = normalizeRoots([await prompt.input(message)]);
74
+ if (roots.length === 0) {
75
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed; pass --workspace <path>.`);
76
+ }
77
+ return { roots, source: "prompt", confirmed: true };
78
+ }
79
+ function savedRootsPrompt(roots) {
80
+ if (roots.length === 1) {
81
+ return `I'm collecting from ${roots[0]}. Still true? [Y/n] `;
82
+ }
83
+ return [
84
+ "I'm collecting from:",
85
+ ...roots.map((root) => `- ${root}`),
86
+ "Still true? [Y/n] ",
87
+ ].join("\n");
88
+ }
89
+ function requirePrompt(options) {
90
+ if (!options.prompt) {
91
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: interactive prompt unavailable.`);
92
+ }
93
+ return options.prompt;
94
+ }
95
+ function likelyBliRootFromCwd(cwd) {
96
+ const parts = cwd.split(path.sep).filter(Boolean);
97
+ const index = parts.lastIndexOf("BLI");
98
+ if (index < 0)
99
+ return null;
100
+ return `${path.sep}${parts.slice(0, index + 1).join(path.sep)}`;
101
+ }
102
+ async function directoryExists(dir) {
103
+ return fs.stat(dir).then((stats) => stats.isDirectory(), () => false);
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {