@bli-cockpit/cli 0.1.18 → 0.1.19

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
@@ -42,18 +42,23 @@ pairing. Already-onboarded users update with
42
42
  `cockpit sync --workspace "$PWD" --json`. `--repo <path>` remains supported
43
43
  for older prompts and the agent ticket-binding guardrail.
44
44
 
45
- On machines where Codex or Claude agents will do ticketed work, install the
46
- user-scope agent rule once:
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.
47
50
 
48
51
  ```bash
52
+ # Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
49
53
  cockpit agent-rules install
50
54
  ```
51
55
 
52
- That updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with the Cockpit
53
- rule to bind known Linear tickets before edits, or ask once when the ticket ID
54
- is missing. That binding starts attributing the session's work to the specific
55
- ticket in Cockpit. It checks for the managed block first, so current files are
56
- left unchanged and stale managed blocks are replaced.
56
+ The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
57
+ the Cockpit rule to bind known Linear tickets before edits, or ask once when
58
+ the ticket ID is missing. That binding starts attributing the session's work to
59
+ the specific ticket in Cockpit. It checks for managed or equivalent guidance
60
+ first, so current files are left unchanged and stale Cockpit ticket-binding
61
+ sections are replaced.
57
62
 
58
63
  Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
59
64
  repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
@@ -24,13 +24,17 @@ async function installAgentRulesForHost(host, options = {}) {
24
24
  catch {
25
25
  existed = false;
26
26
  }
27
- const next = upsertManagedBlock(existing, block);
27
+ const prepared = prepareManagedBlockInstall(existing, block);
28
+ if (!prepared.next) {
29
+ return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
+ }
31
+ const next = prepared.next;
28
32
  if (next === existing) {
29
- return agentRulesResult(host, rulesFile, "unchanged", block);
33
+ return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
34
  }
31
35
  await mkdir(path.dirname(rulesFile), { recursive: true });
32
36
  await writeFile(rulesFile, next, "utf8");
33
- return agentRulesResult(host, rulesFile, existed ? "updated" : "created", block);
37
+ return agentRulesResult(host, rulesFile, existed ? "updated" : "created", block, "managed", prepared.state === "stale");
34
38
  }
35
39
  export async function uninstallCodexAgentRules(options = {}) {
36
40
  return uninstallAgentRulesForHost("codex", options);
@@ -50,14 +54,14 @@ async function uninstallAgentRulesForHost(host, options = {}) {
50
54
  existing = await readFile(rulesFile, "utf8");
51
55
  }
52
56
  catch {
53
- return agentRulesResult(host, rulesFile, "missing", block);
57
+ return agentRulesResult(host, rulesFile, "missing", block, "missing");
54
58
  }
55
59
  const next = removeManagedBlock(existing);
56
60
  if (next === existing) {
57
- return agentRulesResult(host, rulesFile, "missing", block);
61
+ return agentRulesResult(host, rulesFile, "missing", block, "missing");
58
62
  }
59
63
  await writeFile(rulesFile, next, "utf8");
60
- return agentRulesResult(host, rulesFile, "updated", block);
64
+ return agentRulesResult(host, rulesFile, "updated", block, "missing");
61
65
  }
62
66
  export async function inspectCodexAgentRules(options = {}) {
63
67
  return inspectAgentRulesForHost("codex", options);
@@ -80,11 +84,15 @@ async function inspectAgentRulesForHost(host, options = {}) {
80
84
  existing = await readFile(rulesFile, "utf8");
81
85
  }
82
86
  catch {
83
- return { ...agentRulesResult(host, rulesFile, "missing", block), installed: false };
87
+ return {
88
+ ...agentRulesResult(host, rulesFile, "missing", block, "missing"),
89
+ installed: false,
90
+ };
84
91
  }
85
- const installed = hasManagedBlock(existing);
92
+ const state = inspectAgentRulesContents(existing);
93
+ const installed = state === "managed" || state === "equivalent";
86
94
  return {
87
- ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block),
95
+ ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
88
96
  installed,
89
97
  };
90
98
  }
@@ -118,19 +126,48 @@ export function removeManagedBlock(contents) {
118
126
  return contents;
119
127
  return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
120
128
  }
129
+ function prepareManagedBlockInstall(contents, block) {
130
+ if (!contents.trim())
131
+ return { next: `${block}\n`, state: "missing" };
132
+ if (hasManagedBlock(contents)) {
133
+ return { next: upsertManagedBlock(contents, block), state: "managed" };
134
+ }
135
+ if (hasEquivalentUnmanagedTicketBinding(contents)) {
136
+ return { next: null, state: "equivalent" };
137
+ }
138
+ const staleBlock = findStaleUnmanagedTicketBindingBlock(contents);
139
+ if (staleBlock) {
140
+ return {
141
+ next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
142
+ state: "stale",
143
+ };
144
+ }
145
+ return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
146
+ }
147
+ function inspectAgentRulesContents(contents) {
148
+ if (hasManagedBlock(contents))
149
+ return "managed";
150
+ if (hasEquivalentUnmanagedTicketBinding(contents))
151
+ return "equivalent";
152
+ if (findStaleUnmanagedTicketBindingBlock(contents))
153
+ return "stale";
154
+ return "missing";
155
+ }
121
156
  function agentRulesFile(host, homeDir = os.homedir()) {
122
157
  if (host === "claude") {
123
158
  return path.join(homeDir, ".claude", "CLAUDE.md");
124
159
  }
125
160
  return path.join(homeDir, ".codex", "AGENTS.md");
126
161
  }
127
- function agentRulesResult(host, rulesFile, status, block) {
162
+ function agentRulesResult(host, rulesFile, status, block, state, staleBlockReplaced = false) {
128
163
  return {
129
164
  host,
130
165
  status,
131
166
  rules_file: rulesFile,
132
167
  agents_file: rulesFile,
133
168
  block,
169
+ state,
170
+ ...(staleBlockReplaced ? { stale_block_replaced: true } : {}),
134
171
  };
135
172
  }
136
173
  function targetHosts(hosts) {
@@ -151,6 +188,62 @@ function aggregateAgentRulesResult(targets) {
151
188
  function managedBlockPattern() {
152
189
  return new RegExp(`${escapeRegExp(MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(MANAGED_BLOCK_END)}`, "u");
153
190
  }
191
+ function hasEquivalentUnmanagedTicketBinding(contents) {
192
+ const text = normalizeRuleText(contents);
193
+ if (!hasTicketBindingCues(text))
194
+ return false;
195
+ const signals = [
196
+ /cockpit\s+start\s+--ticket\b/u,
197
+ /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
198
+ /ticket\s+id\s+(?:is\s+)?(?:missing|visible)|ask\s+once/u,
199
+ /general\s+ambient/u,
200
+ /cockpit\s+sync\s+--repo|fresh\s+ticket\/session\s+binding\s+metadata/u,
201
+ /use\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
202
+ ];
203
+ const score = signals.filter((signal) => signal.test(text)).length;
204
+ return score >= 5;
205
+ }
206
+ function findStaleUnmanagedTicketBindingBlock(contents) {
207
+ const lines = contents.split("\n");
208
+ for (let index = 0; index < lines.length; index += 1) {
209
+ if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
210
+ continue;
211
+ }
212
+ let endLine = lines.length;
213
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
214
+ if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
215
+ endLine = cursor;
216
+ break;
217
+ }
218
+ }
219
+ const candidate = lines.slice(index, endLine).join("\n");
220
+ const normalized = normalizeRuleText(candidate);
221
+ if (hasTicketBindingCues(normalized) && !hasEquivalentUnmanagedTicketBinding(candidate)) {
222
+ return { startLine: index, endLine };
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+ function replaceLineSpan(contents, startLine, endLine, replacement) {
228
+ const lines = contents.split("\n");
229
+ const before = lines.slice(0, startLine).join("\n").trimEnd();
230
+ const after = lines.slice(endLine).join("\n").trimStart();
231
+ return [before, replacement, after]
232
+ .filter((part) => part.trim().length > 0)
233
+ .join("\n\n")
234
+ .replace(/\n{3,}/gu, "\n\n")
235
+ .trimEnd() + "\n";
236
+ }
237
+ function hasTicketBindingCues(text) {
238
+ return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
239
+ }
240
+ function normalizeRuleText(contents) {
241
+ return contents
242
+ .toLowerCase()
243
+ .replace(/[`"'<>]/gu, "")
244
+ .replace(/\s+/gu, " ")
245
+ .trim();
246
+ }
154
247
  function escapeRegExp(value) {
155
248
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
156
249
  }
@@ -104,6 +104,7 @@ function localSubcommandHelp(command) {
104
104
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
105
105
  "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
106
106
  "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).",
107
+ "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
107
108
  ],
108
109
  ],
109
110
  [
@@ -310,6 +311,48 @@ async function maybeOfferAutostart(command, io) {
310
311
  : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
311
312
  writeLine(io.stdout, `Plist: ${result.plist_path}`);
312
313
  }
314
+ async function maybeOfferAgentRules(command, io) {
315
+ if (command.json || !isInteractiveStdin(io))
316
+ return;
317
+ const current = await inspectAgentRules({ homeDir: command.homeDir });
318
+ if (current.installed) {
319
+ writeLine(io.stdout, onboardAgentRulesAlreadyInstalledLine(current));
320
+ return;
321
+ }
322
+ const answer = (await readLine(io, "Add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md? [Y/n] "))
323
+ .trim()
324
+ .toLowerCase();
325
+ if (answer === "n" || answer === "no") {
326
+ writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install` anytime.");
327
+ return;
328
+ }
329
+ const result = await installAgentRules({ homeDir: command.homeDir });
330
+ writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
331
+ for (const target of result.targets) {
332
+ writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
333
+ }
334
+ }
335
+ function onboardAgentRulesAlreadyInstalledLine(result) {
336
+ const hasEquivalent = result.targets.some((target) => target.state === "equivalent");
337
+ return hasEquivalent
338
+ ? "Agent rules: matching Cockpit ticket-binding guidance already exists."
339
+ : "Agent rules: already current in AGENTS.md and CLAUDE.md.";
340
+ }
341
+ function onboardAgentRulesInstallLine(result) {
342
+ if (result.targets.some((target) => target.stale_block_replaced)) {
343
+ return "updated; replaced stale Cockpit ticket-binding guidance.";
344
+ }
345
+ switch (result.status) {
346
+ case "created":
347
+ return "installed.";
348
+ case "updated":
349
+ return "updated.";
350
+ case "unchanged":
351
+ return "already current.";
352
+ case "missing":
353
+ return "not installed.";
354
+ }
355
+ }
313
356
  async function runOnboard(command, io) {
314
357
  let install = null;
315
358
  let pair = null;
@@ -383,8 +426,10 @@ async function runOnboard(command, io) {
383
426
  codex_sessions: multi.codex_sessions,
384
427
  }, null, 2));
385
428
  }
386
- if (multi.ok)
429
+ if (multi.ok) {
430
+ await maybeOfferAgentRules(command, io);
387
431
  await maybeOfferAutostart(command, io);
432
+ }
388
433
  return multi.ok ? 0 : 1;
389
434
  }
390
435
  const context = await startLocalWorkContext({
@@ -449,6 +494,7 @@ async function runOnboard(command, io) {
449
494
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
450
495
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
451
496
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
497
+ await maybeOfferAgentRules(command, io);
452
498
  await maybeOfferAutostart(command, io);
453
499
  return 0;
454
500
  }
@@ -25,7 +25,7 @@ function cockpitHelp() {
25
25
  "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
26
26
  "Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
27
27
  "Already onboarded: run `cockpit sync --workspace \"$PWD\" --json`.",
28
- "Agent setup: run `cockpit agent-rules install` so Codex asks for or binds Linear tickets before edits.",
28
+ "Agent setup: interactive `cockpit onboard` offers AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install` for repair/headless setup.",
29
29
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
30
30
  "Manual collector path: `install`, `login`, `start [--ticket <id>]`, `sync`, `status`, `agent-rules`.",
31
31
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {