@bli-cockpit/cli 0.1.33 → 0.2.0

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
@@ -1,251 +1,161 @@
1
1
  # Cockpit CLI
2
2
 
3
- Public BLI Cockpit command-line interface for approved operators and interns.
3
+ Cockpit watches how you and your AI agents work so Edward can coach from evidence instead of screenshots and status pings.
4
4
 
5
- The npm package is public; the Cockpit backend and admin tooling are not. The
6
- CLI pairs a local laptop with the private Cockpit dashboard, records safe
7
- work context, uploads consented raw Codex/Claude/diff evidence and explicit
8
- images attached into agent sessions to durable private storage, and uploads
9
- metadata refs after dashboard-approved
10
- device pairing.
5
+ ## 90-second speedrun
11
6
 
12
- ## One-paste install
13
-
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:
7
+ Run these three commands on the Mac that should be collected:
17
8
 
18
9
  ```bash
19
- npm install -g @bli-cockpit/cli@latest
20
- cockpit onboard
10
+ $ npm install -g @bli-cockpit/cli@latest
11
+ added <packages> in <seconds>s
21
12
  ```
22
13
 
23
- Interactive onboarding prompts for the dashboard email. Enter the approved
24
- intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
25
- pass the email and root explicitly:
26
-
27
14
  ```bash
28
- cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
15
+ $ cockpit onboard
16
+ Cockpit harvest onboarding
17
+ Dashboard: https://bli-cockpit-dashboard.vercel.app
18
+ Ticket: general ambient
19
+ What's your @buildlaunchiterate.ca email? (press enter to skip): ian@buildlaunchiterate.ca
20
+ Signing in as ian@buildlaunchiterate.ca.
21
+ Code sent; valid 1h, resend in 60s by rerunning this command.
22
+ Enter the 6-digit code: 482913
23
+ Signed in as ian@buildlaunchiterate.ca.
24
+ 2/5 Device paired.
25
+ PASS: Cockpit collector is ready for harvest.
26
+ You're live.
27
+ Dashboard: https://bli-cockpit-dashboard.vercel.app/my-work
28
+ Next: cockpit status
29
29
  ```
30
30
 
31
- If a work folder contains multiple repos, confirm the parent folder as the
32
- collection root. In a headless setup, pass it explicitly:
33
-
34
31
  ```bash
35
- npm install -g @bli-cockpit/cli@latest
36
- cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
32
+ $ cockpit status
33
+ Install: ready
34
+ Auth: paired
35
+ Upload state: ready
37
36
  ```
38
37
 
39
- For multiple unrelated collection roots, repeat `--workspace`:
38
+ The OTP proves you own an approved BLI mailbox. The JWT is used once to register this device and is never saved; the durable local credential remains the existing Cockpit device token.
39
+
40
+ ## Every command, what it does, and why it's called that
41
+
42
+ You only need three commands (`onboard`, `status`, and `backfill` once). The rest exist for
43
+ recovery and maintenance.
44
+
45
+ | Command | What it does | Why it exists / why this name |
46
+ |---|---|---|
47
+ | `cockpit onboard` | The everything-command: signs you in (email code), registers this Mac, starts capture, uploads once, installs the 15-min background sync, and prints proof you're live. | You are boarding the crew. Run it once per machine; rerunning is always safe. |
48
+ | `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
49
+ | `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this Mac). | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
50
+ | `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
51
+ | `cockpit start --ticket <id>` | Tags your CURRENT work with a Linear ticket so sessions attribute to it. `--clear-ticket` returns to general capture. | "Start (working on) X." Agents usually run this for you per the agent rules. |
52
+ | `cockpit login` / `cockpit pair` | Just the sign-in + device-registration step, standalone (onboard already includes it). Two names, one command: `login` is what humans guess, `pair` is what the dashboard's device screen calls it. | Recovery path when a session expires or you switch accounts. |
53
+ | `cockpit logout` | Deletes this machine's session. | The undo of login. |
54
+ | `cockpit update` / `cockpit upgrade` | Updates the CLI itself from npm, then reruns onboard. Two names, one command — aliases because half of people guess each. | Self-update; run when Edward announces a new version. |
55
+ | `cockpit install` | Writes local config only (step 1 of onboard, standalone). | Plumbing; exists so onboard's pieces are individually runnable. You'll likely never type it. |
56
+ | `cockpit sessions` | Read-only: lists your observed sessions and how each attributed. | Debugging "why isn't my session showing up?" without uploading anything. |
57
+ | `cockpit autostart install\|uninstall\|status` | Manages the macOS launchd agent behind the 15-min background sync. | Direct control of the background piece when you need to stop/inspect it. |
58
+ | `cockpit agent-rules install\|uninstall\|status` | Manages the Cockpit ticket-binding text block inside `~/.codex/AGENTS.md` / `~/.claude/CLAUDE.md`. | It's literally "the rules your agents read." Passive markdown — never an executable hook. |
59
+ | `cockpit serve` | Runs a tiny local HTTP status server. | Niche: lets other tools ask the collector how it's doing. |
60
+ | `cockpit logout`, `cockpit release`, `cockpit admin *` | `release` publishes the CLI (maintainer, repo-gated); `admin` (bootstrap/invite) is absent from the public build entirely. | Maintainer surface — interns never touch these. |
61
+
62
+ Honest naming notes: `update`/`upgrade` and `login`/`pair` are deliberate synonym-aliases (cheap
63
+ forgiveness beats a "command not found"); `install` is a misleading name for "write config" but
64
+ is kept for onboard-step symmetry.
65
+
66
+ ## Flags
67
+
68
+ Normal setup needs no flags. Use flags only when the default guess is wrong:
40
69
 
41
70
  ```bash
42
- cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-projects
71
+ cockpit onboard --email <you@buildlaunchiterate.ca> --workspace ~/BLI
72
+ cockpit onboard --workspace ~/BLI --workspace ~/side-projects
73
+ cockpit onboard --device-name "Ian MacBook"
74
+ cockpit onboard --no-auth
43
75
  ```
44
76
 
45
- The CLI defaults to the production dashboard. Normal intern/operator setup,
46
- updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
47
- staging, a custom dashboard, or deliberately forcing a different dashboard
48
- pairing. Already-onboarded users update with `cockpit update` from anywhere. It
49
- installs the latest public CLI from npm, then reruns onboarding checks to
50
- refresh pairing, agent rules, autostart, and an initial sync against saved
51
- roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
52
- supported for older prompts and the agent ticket-binding guardrail.
53
- When onboarding reports archived local sessions without a completed backfill,
54
- follow [`docs/runbooks/cockpit-backfill.md`](../../docs/runbooks/cockpit-backfill.md):
55
- run `cockpit update`, then run `cockpit backfill --all` as a separate command.
56
-
57
- On machines where Codex or Claude agents will do ticketed work, `cockpit
58
- onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
59
- proof. The managed Cockpit ticket-binding block is replaced in place rather
60
- than duplicated and is scoped to the confirmed root set, so agents should ignore
61
- it in private chats or unrelated repos.
62
-
63
- For intern machines, `cockpit onboard` refreshes the launchd autostart agent so
64
- Cockpit syncs confirmed roots at login and every 15 minutes. For a manual
65
- repair path, run `cockpit autostart install --workspace <root>` later.
77
+ - `--email` skips the email prompt.
78
+ - `--workspace` pins one collection root; repeat it for multiple unrelated roots.
79
+ - `--device-name` changes only the human label shown in Cockpit.
80
+ - `--dashboard-url` is for staging/custom dashboards only. Production is the default.
81
+ - `--no-auth` forces the old manual approval queue.
82
+ - `--repo` still works as a legacy alias for `--workspace`.
83
+
84
+ `cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
85
+
86
+ ## Parent Mode
87
+
88
+ Use a parent folder such as `~/BLI` when it contains multiple repos. Cockpit scans child git repos/worktrees, creates one stable work context per worktree, and rolls them up under repo rows in the dashboard.
89
+
90
+ Defaults: 3 folder levels deep, up to 50 repos. Tune with `--max-depth` and `--max-repos`.
91
+
92
+ ## Pairing Recovery
93
+
94
+ The v2 happy path auto-approves pairing after the email OTP. Manual approval still exists for recovery:
66
95
 
67
96
  ```bash
68
- # Repair/manual path.
69
- cockpit agent-rules install --workspace <root>
97
+ cockpit onboard --no-auth --email <you@buildlaunchiterate.ca> --workspace ~/BLI
70
98
  ```
71
99
 
72
- The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
73
- the Cockpit rule to bind known Linear tickets before edits, or ask once when
74
- the ticket ID is missing inside that workspace. That binding starts attributing
75
- the session's work to the specific ticket in Cockpit. It checks for managed or
76
- equivalent guidance first, so current files are left unchanged and stale Cockpit
77
- ticket-binding sections are replaced.
78
-
79
- Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
80
- repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
81
- if the repo cap truncates discovery), creates one stable work context per
82
- worktree, and the dashboard rolls them up under one repo row with worktree
83
- drilldown. Repos cloned later are picked up automatically: `cockpit sync`
84
- starts a general ambient work context for newly discovered repos on its own. Device approval is still one time per laptop/dashboard/email. Codex
85
- JSONL transcripts are attributed to a repo/worktree deterministically (session
86
- cwd, workspace roots, git origin/branch/commit signals) and upload only into
87
- that repo's evidence lane; transcripts that cannot be attributed to exactly one
88
- worktree are reported as ambiguous/unattributed with reason labels instead of
89
- being duplicated across repos or dropped.
90
-
91
- What happens:
92
-
93
- 1. npm installs or updates the public `@bli-cockpit/cli` package.
94
- 2. `cockpit onboard` writes local user config.
95
- 3. Cockpit prints a dashboard pairing URL and code.
96
- 4. Admin approves the persistent email access request from Ambient -> Collector
97
- approvals. Pair codes still expire, but the access request remains. If the
98
- terminal is still polling, approval pairs it immediately; if not, the intern
99
- reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI` or
100
- simply `cockpit onboard` after roots are saved, and the stored approval
101
- auto-pairs without another Admin action. Admin can still paste the printed
102
- code there as a rescue path. The signed-in intern can still use the printed
103
- URL. If the claimed email is not an app user yet, a super admin approval
104
- creates the user, adds team membership, and sends the Supabase invite flow
105
- automatically.
106
- 5. The CLI starts general ambient capture, uploads private raw evidence objects
107
- when present (chunked and resumable, with identical content acknowledged
108
- instead of re-uploaded), then uploads one safe metadata/ref envelope. Text
109
- evidence with secret-shaped values is sanitized before storage and marked as
110
- partial evidence with redaction metadata; env files are still never read.
111
- 6. The CLI prints `PASS: Cockpit collector is ready for harvest.` and a final
112
- `You're live.` block with collection roots, pairing state, background sync
113
- state, dashboard link, and `cockpit status` as the next command.
114
-
115
- `--device-name` is optional and only a readable label in Cockpit. Pass it only
116
- when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
117
- On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
118
- `cockpit onboard` skips pairing only when the existing valid session belongs to
119
- that same email and dashboard URL (the production default unless overridden); a
120
- different email or dashboard forces a new approval.
121
-
122
- Before the command runs, an Admin does not need to open Supabase or pre-create
123
- ordinary interns in Settings -> Team. Approval of the claimed email in Ambient
124
- creates the missing app user and stores the durable access approval. Settings
125
- -> Team remains available for manual pre-provisioning, including
126
- temporary-password launch. For temporary-password accounts, the intern signs in
127
- directly; there is no separate invite acceptance step.
128
-
129
- When ticket work starts later:
100
+ Use that when the mailbox is unavailable, auth routes are down, or Edward has added an exact non-domain email exception. Cockpit prints a URL and code; Admin approves from Ambient -> Collector approvals. If the code expires, rerun `cockpit onboard` after approval and the durable access row will auto-pair.
130
101
 
131
- ```bash
132
- cockpit start \
133
- --ticket <ticket-id> \
134
- --workspace "$PWD"
102
+ If this is a reused laptop or VM, keep `--email` set. Cockpit reuses an existing session only when it belongs to that same email and dashboard URL.
135
103
 
136
- cockpit sync \
137
- --workspace "$PWD" \
138
- --json
139
- ```
104
+ ## Ticket Semantics
105
+
106
+ Setup is general ambient capture. Do not invent a ticket for onboarding.
140
107
 
141
- No ticket is required for setup, chatting, planning, or general ambient capture.
142
- Only pass `--ticket` when the work really belongs to a visible ticket.
143
- Omitting `--ticket` preserves any existing ticket binding; run
144
- `cockpit start --clear-ticket --workspace "$PWD"` when you intentionally want
145
- to return a previously bound work context to general ambient capture.
108
+ When real ticket work starts:
146
109
 
147
- When the work is important but ticketless, label it explicitly before syncing so
148
- later analysis does not have to guess the topic:
110
+ ```bash
111
+ cockpit start --ticket <ticket-id> --workspace "$PWD"
112
+ cockpit sync --workspace "$PWD" --json
113
+ ```
114
+
115
+ For important ticketless work:
149
116
 
150
117
  ```bash
151
118
  cockpit start \
152
119
  --workspace "$PWD" \
153
- --topic "lead ingestion rewrite planning" \
120
+ --topic "lead ingestion planning" \
154
121
  --intent planning \
155
122
  --phase discovery \
156
123
  --intent-confidence 0.9
157
-
158
- cockpit sync \
159
- --workspace "$PWD" \
160
- --json
161
124
  ```
162
125
 
163
- Supported `--intent` values are `implementation`, `bug_fix`,
164
- `root_cause_analysis`, `planning`, `discovery`, `review`, `testing`,
165
- `documentation`, `release`, `learning`, `coordination`, `maintenance`,
166
- `analysis`, `unknown`, and `other`. Supported `--phase` values are `planning`,
167
- `discovery`, `implementation`, `debugging`, `review`, `testing`,
168
- `documentation`, `release`, `handoff`, `analysis`, `unknown`, and `other`.
169
- Use `--topic-summary` only for short redacted summaries, not transcript text.
126
+ `cockpit onboard` also refreshes managed ticket-binding guidance in `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` so agents bind visible Linear tickets before edits inside the confirmed workspace.
170
127
 
171
- ## What gets saved
128
+ ## Storage Layout
172
129
 
173
130
  Local files:
174
131
 
175
- - `~/.config/bli-cockpit/config.json`: dashboard URL (production default unless
176
- overridden) and local install config.
177
- - `~/.config/bli-cockpit/session.json`: normal paired device session.
178
- - `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
179
- - `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
180
- already-durable evidence so repeated syncs skip re-uploading the same
181
- content (no raw content is stored in the cursor).
182
- - `~/.local/state/bli-cockpit/cursors/backfill.json`: high-water progress for
183
- the explicit `cockpit backfill` command.
184
- - `.codex-autorunner/contextspace/active_context.md` in the work repo when a
185
- work context is active.
186
-
187
- Remote dashboard:
188
-
189
- - approved user and device identity;
190
- - repo/worktree fingerprints, branch, head SHA, optional ticket, source
191
- availability, risk flags, and upload timestamps;
192
- - raw evidence refs accepted by `/api/ambient/ingest`;
193
- - durable private Storage objects accepted by the chunked
194
- `/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
195
- durable per-object upload ledger. When deterministic sanitization was
196
- applied, the evidence ref stores the redaction metadata and sanitized object
197
- hash/size;
198
- - attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
199
- for screenshots/images explicitly attached into Codex or Claude sessions;
200
- - Codex session attribution records (session id, file hash, attribution state
201
- and reason labels, scores) accepted by `/api/ambient/codex-sessions`.
202
- The local collector scans both active `~/.codex/sessions` files and archived
203
- `~/.codex/archived_sessions` files, using only `session_meta` and
204
- `turn_context` metadata for attribution. Transcript bytes are sanitized or
205
- blocked later by raw evidence collection before upload.
206
-
207
- New raw evidence object keys are readable from the Storage browser:
208
-
209
- ```text
210
- operators/<operator-slug>-<operator-short>/repos/<repo-slug>/worktrees/<worktree-slug>/tickets/<ticket-id-or-unbound>/dates/<YYYY-MM-DD>/sessions/<session-slug>/ids/<operator_user_id>/<work_context_id>/<source>/<source-path>
211
- ```
132
+ - `~/.config/bli-cockpit/config.json`: dashboard URL, device label, collection roots.
133
+ - `~/.config/bli-cockpit/session.json`: paired device token and owner metadata.
134
+ - `~/.local/state/bli-cockpit/spool/`: safe retry records.
135
+ - `~/.local/state/bli-cockpit/cursors/`: upload/backfill cursors, no raw content.
136
+ - `.codex-autorunner/contextspace/active_context.md`: current work context inside a repo.
212
137
 
213
- The readable folders are for humans. The canonical join still lives in
214
- `ambient_evidence_refs.raw_evidence_pointer_id`, `storage_bucket`, and
215
- `object_key`. For SQL recipes that reconstruct a day of work by operator, date,
216
- repo, ticket, and raw JSONL object, see
217
- [`docs/runbooks/cockpit-data-traceability.md`](../../docs/runbooks/cockpit-data-traceability.md).
138
+ Remote data:
218
139
 
219
- ## Read or debug captured data
140
+ - approved user/device identity;
141
+ - repo/worktree fingerprints, branch, head SHA, optional ticket, source counts;
142
+ - private raw evidence objects and `ambient_evidence_refs` pointers;
143
+ - Codex/Claude session attribution metadata.
220
144
 
221
- Local attribution preview:
145
+ Never provide service-role keys, raw DB URLs, cookies, root env files, or deployment tokens to this CLI. The collector never reads env files and does not collect random desktop screenshots or screen recordings.
146
+
147
+ ## Debug
222
148
 
223
149
  ```bash
224
150
  cockpit sessions --workspace "$PWD" --json
151
+ cockpit status --workspace "$PWD" --json
152
+ cockpit sync --workspace "$PWD" --json
225
153
  ```
226
154
 
227
- This preview includes both active and archived Codex sessions in the bounded
228
- scan window, plus Claude Code sessions when Claude collection is enabled. Use
229
- `--since-days N` or `--all` when debugging older local history.
230
-
231
- Remote metadata path:
155
+ If `cockpit update` fails with npm `EACCES`, fix Homebrew global-package ownership once:
232
156
 
233
- 1. Query `ambient_evidence_refs` by `operator_user_id`, `received_at`, and
234
- optional `ticket_id`.
235
- 2. Join `ambient_work_sessions` only for current labels such as repo, worktree,
236
- branch, and collector version.
237
- 3. Open the `ambient-raw-evidence` object only after SQL identifies the exact
238
- row and `object_key`.
239
-
240
- Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
241
- or deployment tokens to this CLI. The collector must never read env files and
242
- does not collect random desktop screenshots or screen recordings.
243
-
244
- ## Public package boundary
157
+ ```bash
158
+ sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit
159
+ ```
245
160
 
246
- This public package intentionally excludes Cockpit admin bootstrap commands,
247
- service-role credential handling, source maps, tests, and internal runbooks.
248
- Clean `npm pack` and `npm publish` run the public CLI build before packaging so
249
- `dist/cli.js` is present in emergency releases.
250
- When collector changes depend on new telemetry-core exports, publish
251
- `@bli-cockpit/telemetry-core` first, then publish `@bli-cockpit/cli`.
161
+ Do not use `sudo npm i -g`; it recreates the ownership problem.
@@ -56,6 +56,7 @@ function parseOnboardLikeArgs(args, command) {
56
56
  "--ticket",
57
57
  "--branch",
58
58
  "--json",
59
+ "--no-auth",
59
60
  "--poll-interval-ms",
60
61
  "--timeout-ms",
61
62
  "--max-depth",
@@ -87,6 +88,7 @@ function parseOnboardLikeArgs(args, command) {
87
88
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
88
89
  activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
89
90
  branch: optionalNonEmpty(values.flags.get("--branch")),
91
+ noAuth: values.booleans.has("--no-auth"),
90
92
  json: values.booleans.has("--json"),
91
93
  pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
92
94
  timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
@@ -164,6 +166,7 @@ function parseLoginArgs(args) {
164
166
  "--email",
165
167
  "--device-name",
166
168
  "--json",
169
+ "--no-auth",
167
170
  "--poll-interval-ms",
168
171
  "--timeout-ms",
169
172
  ],
@@ -184,6 +187,7 @@ function parseLoginArgs(args) {
184
187
  claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
185
188
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
186
189
  json: values.booleans.has("--json"),
190
+ noAuth: values.booleans.has("--no-auth"),
187
191
  pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
188
192
  timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
189
193
  };
@@ -90,12 +90,12 @@ export function localCommandHelp(command) {
90
90
  if (command)
91
91
  return localSubcommandHelp(command);
92
92
  return [
93
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
94
- " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
93
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
94
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--no-auth] [--json]",
95
95
  " cockpit upgrade [same flags as update]",
96
96
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
97
- " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
98
- " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
97
+ " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
98
+ " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
99
  " cockpit logout",
100
100
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
101
101
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -115,14 +115,14 @@ function localSubcommandHelp(command) {
115
115
  [
116
116
  "onboard",
117
117
  [
118
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--json]",
118
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--no-auth] [--json]",
119
119
  "",
120
120
  "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
121
121
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
122
122
  "`--repo <path>` remains supported as a backward-compatible alias.",
123
123
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
124
124
  "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
125
- "Run with no flags in a terminal and it prompts for the dashboard email; pass --email to skip the prompt (and on shared/reused machines, where mismatched sessions are re-paired).",
125
+ "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.",
126
126
  "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
127
127
  ],
128
128
  ],
@@ -139,7 +139,7 @@ function localSubcommandHelp(command) {
139
139
  [
140
140
  "update",
141
141
  [
142
- "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
142
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--no-auth] [--json]",
143
143
  "",
144
144
  "Updates the global public CLI from npm, then reruns `cockpit onboard`",
145
145
  "with the same setup flags so pairing, saved roots, agent rules,",
@@ -158,16 +158,16 @@ function localSubcommandHelp(command) {
158
158
  [
159
159
  "login",
160
160
  [
161
- "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
161
+ "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
162
162
  "",
163
- "Starts dashboard device pairing and stores the approved local session.",
163
+ "Signs in with an email OTP when interactive, starts dashboard device pairing, and stores the approved local session.",
164
164
  "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
165
165
  ],
166
166
  ],
167
167
  [
168
168
  "pair",
169
169
  [
170
- "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
170
+ "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
171
171
  "",
172
172
  "Alias for `cockpit login`.",
173
173
  ],
@@ -330,6 +330,10 @@ async function runUpdate(command, io) {
330
330
  }
331
331
  else {
332
332
  writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
333
+ if (isNpmEaccesFailure(install.stderr)) {
334
+ writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
335
+ writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
336
+ }
333
337
  }
334
338
  return install.code || 1;
335
339
  }
@@ -437,6 +441,8 @@ function updateOnboardArgs(command) {
437
441
  }
438
442
  if (command.claimedOwnerEmail)
439
443
  args.push("--email", command.claimedOwnerEmail);
444
+ if (command.noAuth)
445
+ args.push("--no-auth");
440
446
  if (command.deviceName)
441
447
  args.push("--device-name", command.deviceName);
442
448
  if (command.activeTicketId)
@@ -457,6 +463,9 @@ function updateOnboardArgs(command) {
457
463
  args.push("--json");
458
464
  return args;
459
465
  }
466
+ function isNpmEaccesFailure(stderr) {
467
+ return /EACCES|permission denied/i.test(stderr);
468
+ }
460
469
  function updateCollectionRoots(command) {
461
470
  const roots = command.collectionRoots?.length
462
471
  ? command.collectionRoots
@@ -514,7 +523,7 @@ async function readLine(io, prompt) {
514
523
  * leniency (the approving admin's account then owns the device).
515
524
  */
516
525
  async function promptOnboardEmail(io) {
517
- const raw = await readLine(io, "Dashboard email (press enter to skip): ");
526
+ const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
518
527
  const answer = raw.trim().toLowerCase();
519
528
  if (!answer)
520
529
  return undefined;
@@ -558,6 +567,112 @@ async function resolveOnboardEmail(command, roots, config, io) {
558
567
  }
559
568
  return promptOnboardEmail(io);
560
569
  }
570
+ async function resolveInteractiveLoginEmail(command, io) {
571
+ if (command.claimedOwnerEmail)
572
+ return command.claimedOwnerEmail;
573
+ if (command.noAuth || command.json || !isInteractiveStdin(io)) {
574
+ return undefined;
575
+ }
576
+ return promptOnboardEmail(io);
577
+ }
578
+ async function requestPairingAccessToken(input, io) {
579
+ if (!input.email ||
580
+ input.noAuth ||
581
+ input.json ||
582
+ !isInteractiveStdin(io)) {
583
+ return undefined;
584
+ }
585
+ try {
586
+ const fetchImpl = io.fetch;
587
+ writeLine(io.stdout, `Signing in as ${input.email}.`);
588
+ const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
589
+ const resendAfter = typeof start.resend_after_seconds === "number"
590
+ ? start.resend_after_seconds
591
+ : 60;
592
+ writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
593
+ const code = (await readLine(io, "Enter the 6-digit code: ")).trim();
594
+ if (!/^\d{6}$/.test(code)) {
595
+ throw new Error("OTP code must be 6 digits.");
596
+ }
597
+ const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
598
+ if (typeof verified.access_token !== "string" || !verified.access_token) {
599
+ throw new Error("OTP verified but dashboard returned no access token.");
600
+ }
601
+ writeLine(io.stdout, `Signed in as ${input.email}.`);
602
+ return verified.access_token;
603
+ }
604
+ catch (error) {
605
+ writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
606
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
607
+ return undefined;
608
+ }
609
+ }
610
+ async function pairLocalCollectorWithAuthFallback(options, io) {
611
+ try {
612
+ return await pairLocalCollector(options);
613
+ }
614
+ catch (error) {
615
+ if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
616
+ throw error;
617
+ }
618
+ writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
619
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
620
+ return pairLocalCollector({
621
+ ...options,
622
+ pairingAccessToken: undefined,
623
+ });
624
+ }
625
+ }
626
+ function isPairingAuthFailure(error) {
627
+ return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
628
+ }
629
+ async function postOtpStart(fetchImpl, dashboardUrl, email) {
630
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
631
+ method: "POST",
632
+ headers: { "Content-Type": "application/json" },
633
+ body: JSON.stringify({ email }),
634
+ });
635
+ const parsed = await readJsonResponse(response);
636
+ if (!response.ok) {
637
+ throw new Error(responseErrorMessage(parsed, "OTP start failed"));
638
+ }
639
+ return parsed;
640
+ }
641
+ async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
642
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
643
+ method: "POST",
644
+ headers: { "Content-Type": "application/json" },
645
+ body: JSON.stringify({ email, code }),
646
+ });
647
+ const parsed = await readJsonResponse(response);
648
+ if (!response.ok) {
649
+ throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
650
+ }
651
+ return parsed;
652
+ }
653
+ async function readJsonResponse(response) {
654
+ const text = await response.text();
655
+ if (!text)
656
+ return null;
657
+ try {
658
+ return JSON.parse(text);
659
+ }
660
+ catch {
661
+ return text;
662
+ }
663
+ }
664
+ function responseErrorMessage(parsed, fallback) {
665
+ if (parsed && typeof parsed === "object") {
666
+ const record = parsed;
667
+ if (typeof record["message"] === "string" && record["message"].trim()) {
668
+ return record["message"];
669
+ }
670
+ if (typeof record["error"] === "string" && record["error"].trim()) {
671
+ return record["error"];
672
+ }
673
+ }
674
+ return fallback;
675
+ }
561
676
  async function inferOnboardEmail(homeDir, roots, config) {
562
677
  const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
563
678
  const sessionEmail = normalizeEmailForComparison(session?.email);
@@ -748,18 +863,25 @@ async function runOnboard(command, io) {
748
863
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
749
864
  }
750
865
  }
751
- pair = await pairLocalCollector({
866
+ const pairingAccessToken = await requestPairingAccessToken({
867
+ dashboardUrl: command.dashboardUrl,
868
+ email: claimedOwnerEmail,
869
+ noAuth: command.noAuth,
870
+ json: command.json,
871
+ }, io);
872
+ pair = await pairLocalCollectorWithAuthFallback({
752
873
  homeDir: command.homeDir,
753
874
  dashboardUrl: command.dashboardUrl,
754
875
  claimedOwnerEmail,
755
876
  deviceName: command.deviceName,
756
877
  pollIntervalMs: command.pollIntervalMs,
757
878
  timeoutMs: command.timeoutMs,
879
+ pairingAccessToken,
758
880
  fetch: io.fetch,
759
881
  onPairStarted: command.json
760
882
  ? undefined
761
883
  : (request) => writePairingInstructions(io, request),
762
- });
884
+ }, io);
763
885
  if (!command.json) {
764
886
  writeLine(io.stdout, "2/5 Device paired.");
765
887
  writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
@@ -1107,13 +1229,22 @@ function worktreeSyncRow(outcome, run) {
1107
1229
  };
1108
1230
  }
1109
1231
  async function runLogin(command, io) {
1110
- const result = await pairLocalCollector({
1232
+ const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
1233
+ const dashboardUrl = await resolveLoginDashboardUrl(command);
1234
+ const pairingAccessToken = await requestPairingAccessToken({
1235
+ dashboardUrl,
1236
+ email: claimedOwnerEmail,
1237
+ noAuth: command.noAuth,
1238
+ json: command.json,
1239
+ }, io);
1240
+ const result = await pairLocalCollectorWithAuthFallback({
1111
1241
  homeDir: command.homeDir,
1112
1242
  dashboardUrl: command.dashboardUrl,
1113
- claimedOwnerEmail: command.claimedOwnerEmail,
1243
+ claimedOwnerEmail,
1114
1244
  deviceName: command.deviceName,
1115
1245
  pollIntervalMs: command.pollIntervalMs,
1116
1246
  timeoutMs: command.timeoutMs,
1247
+ pairingAccessToken,
1117
1248
  fetch: io.fetch,
1118
1249
  onPairStarted: command.json
1119
1250
  ? undefined
@@ -1123,7 +1254,7 @@ async function runLogin(command, io) {
1123
1254
  writeLine(io.stdout, `Code: ${request.user_code}`);
1124
1255
  writeLine(io.stdout, "Waiting for dashboard approval...");
1125
1256
  },
1126
- });
1257
+ }, io);
1127
1258
  if (command.json) {
1128
1259
  writeLine(io.stdout, JSON.stringify(result, null, 2));
1129
1260
  return 0;
@@ -1135,6 +1266,13 @@ async function runLogin(command, io) {
1135
1266
  writeLine(io.stdout, "Next: run `cockpit start` inside the repo.");
1136
1267
  return 0;
1137
1268
  }
1269
+ async function resolveLoginDashboardUrl(command) {
1270
+ if (command.dashboardUrl)
1271
+ return command.dashboardUrl;
1272
+ const paths = getCollectorRuntimePaths(command.homeDir);
1273
+ const config = await readLocalCollectorConfig(paths);
1274
+ return config.dashboard_url;
1275
+ }
1138
1276
  function writePairingInstructions(io, request) {
1139
1277
  writeLine(io.stdout, "2/5 Device pairing started.");
1140
1278
  writeLine(io.stdout, `Open: ${request.approve_url}`);
@@ -106,7 +106,7 @@ export async function pairLocalCollector(options = {}) {
106
106
  device_name: deviceName,
107
107
  claimed_owner_email: claimedOwnerEmail,
108
108
  collector_version: LOCAL_COLLECTOR_VERSION,
109
- });
109
+ }, options.pairingAccessToken);
110
110
  options.onPairStarted?.(startResponse);
111
111
  const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
112
112
  paths,
@@ -459,10 +459,13 @@ function normalizeDashboardUrl(value) {
459
459
  throw new Error("Dashboard URL cannot be empty.");
460
460
  return normalized;
461
461
  }
462
- async function postPairStart(fetchImpl, dashboardUrl, body) {
462
+ async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
463
463
  const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
464
464
  method: "POST",
465
- headers: { "Content-Type": "application/json" },
465
+ headers: {
466
+ "Content-Type": "application/json",
467
+ ...(accessToken ? { "Authorization": `Bearer ${accessToken}` } : {}),
468
+ },
466
469
  body: JSON.stringify(body),
467
470
  });
468
471
  const parsed = await readResponseJson(response);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.33",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {