@bli-cockpit/cli 0.2.57 → 0.2.59
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/dist/commands/browser-open.js +88 -0
- package/dist/commands/docs.js +27 -6
- package/dist/commands/doctor-report.js +17 -1
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/heartbeat.js +65 -1
- package/dist/commands/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/jarvis-answer-envelope.js +80 -0
- package/dist/commands/jarvis-turn.js +16 -1
- package/dist/commands/jarvis.js +3 -0
- package/dist/commands/local-args-collector-setup.js +17 -0
- package/dist/commands/local-args-tower-admin.js +12 -2
- package/dist/commands/local-args-tower-docs-msg.js +95 -6
- package/dist/commands/local-args-tower-search.js +50 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +29 -14
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +643 -0
- package/dist/commands/local-help.js +12 -551
- package/dist/commands/local.js +9 -0
- package/dist/commands/login.js +91 -8
- package/dist/commands/memory-install-claude.js +35 -15
- package/dist/commands/memory-install-codex-hooks.js +200 -0
- package/dist/commands/memory-install-codex.js +12 -2
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +113 -0
- package/dist/commands/memory-install.js +99 -276
- package/dist/commands/msg.js +85 -2
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/onboard-completion.js +47 -0
- package/dist/commands/onboard-setup.js +82 -2
- package/dist/commands/ops-render-memory.js +76 -0
- package/dist/commands/ops-render.js +13 -1
- package/dist/commands/ops.js +65 -3
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +122 -0
- package/dist/commands/setup-receipt-lines.js +71 -0
- package/dist/commands/setup-receipt.js +241 -0
- package/dist/commands/status.js +20 -1
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- package/dist/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- package/package.json +7 -7
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openInBrowser } from "./browser-open.js";
|
|
2
|
+
import { errorMessage, writeLine } from "./cli-io.js";
|
|
2
3
|
import { pairLocalCollectorWithAuthFallback, requestPairingAccessToken, resolveInteractiveLoginEmail, } from "./local-auth.js";
|
|
3
4
|
import { ensureLocalCollectorConfig } from "../local-state.js";
|
|
5
|
+
import { pairLocalCollectorViaLink, } from "../local-state-pairing-code.js";
|
|
6
|
+
const TAG = "[login]";
|
|
4
7
|
export async function runLogin(command, io) {
|
|
5
8
|
// Standalone `cockpit login` must work on a fresh machine: bootstrap a
|
|
6
9
|
// minimal rootless config when onboard/install has not run yet, instead of
|
|
@@ -13,6 +16,23 @@ export async function runLogin(command, io) {
|
|
|
13
16
|
if (created && !command.json) {
|
|
14
17
|
writeLine(io.stdout, `First login on this machine; wrote collector config: ${paths.config_file}`);
|
|
15
18
|
}
|
|
19
|
+
if (!command.legacyPair) {
|
|
20
|
+
const linked = await tryOneLinkLogin(command, dashboardUrl, io);
|
|
21
|
+
if (linked) {
|
|
22
|
+
reportPaired(command, io, {
|
|
23
|
+
email: linked.session.email ?? linked.session.auth_subject_id,
|
|
24
|
+
deviceName: linked.session.device_name ?? linked.session.device_id ?? "unknown",
|
|
25
|
+
sessionFile: linked.session_file,
|
|
26
|
+
json: linked,
|
|
27
|
+
});
|
|
28
|
+
writeNextStep(command, io, config.default_repo_paths.length > 0);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
// `--pair <code>` is a code somebody minted on purpose; falling back to a
|
|
32
|
+
// second sign-in ceremony would be answering a different question.
|
|
33
|
+
if (command.pairCode)
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
16
36
|
const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
|
|
17
37
|
const pairingAccessToken = await requestPairingAccessToken({
|
|
18
38
|
dashboardUrl,
|
|
@@ -38,16 +58,79 @@ export async function runLogin(command, io) {
|
|
|
38
58
|
writeLine(io.stdout, "Waiting for dashboard approval...");
|
|
39
59
|
},
|
|
40
60
|
}, io);
|
|
61
|
+
reportPaired(command, io, {
|
|
62
|
+
email: result.session.email ?? result.session.auth_subject_id,
|
|
63
|
+
deviceName: result.session.device_name ?? result.session.device_id ?? "unknown",
|
|
64
|
+
sessionFile: result.session_file,
|
|
65
|
+
json: result,
|
|
66
|
+
});
|
|
67
|
+
writeNextStep(command, io, config.default_repo_paths.length > 0);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The one-link ceremony. Returns null when it could not finish, having already
|
|
72
|
+
* said why — the caller then runs the legacy arm rather than stopping, because
|
|
73
|
+
* a machine that cannot pair is worse than a machine that pairs the old way.
|
|
74
|
+
*/
|
|
75
|
+
async function tryOneLinkLogin(command, dashboardUrl, io) {
|
|
76
|
+
try {
|
|
77
|
+
return await pairLocalCollectorViaLink({
|
|
78
|
+
homeDir: command.homeDir,
|
|
79
|
+
dashboardUrl,
|
|
80
|
+
deviceName: command.deviceName,
|
|
81
|
+
claimedOwnerEmail: command.claimedOwnerEmail,
|
|
82
|
+
pairCode: command.pairCode,
|
|
83
|
+
pollIntervalMs: command.pollIntervalMs,
|
|
84
|
+
timeoutMs: command.timeoutMs,
|
|
85
|
+
fetch: io.fetch,
|
|
86
|
+
onLinkReady: command.json
|
|
87
|
+
? undefined
|
|
88
|
+
: (link) => announceLink(command, io, link),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const reason = error && typeof error === "object" && "reason" in error
|
|
93
|
+
? String(error.reason)
|
|
94
|
+
: "pair_link_failed";
|
|
95
|
+
console.error(`${TAG} one-link pairing did not finish`, JSON.stringify({ reason, falling_back: !command.pairCode }));
|
|
96
|
+
writeLine(io.stderr, `Sign-in link did not finish: ${errorMessage(error)}`);
|
|
97
|
+
writeLine(io.stderr, command.pairCode
|
|
98
|
+
? "That code was minted in a browser; run `cockpit login` with no flags to start over."
|
|
99
|
+
: "Falling back to the email-code sign-in.");
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Print the link, then try to open it. Printing first is what makes the open optional. */
|
|
104
|
+
function announceLink(command, io, link) {
|
|
105
|
+
writeLine(io.stdout, "Sign in to Tower once, and this machine is connected.");
|
|
106
|
+
writeLine(io.stdout, `Open: ${link.connect_url}`);
|
|
107
|
+
writeLine(io.stdout, "Waiting for that sign-in...");
|
|
108
|
+
if (command.noBrowser) {
|
|
109
|
+
console.error(`${TAG} browser not opened`, JSON.stringify({ reason: "no_browser_flag" }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const opened = openInBrowser(link.connect_url);
|
|
113
|
+
console.error(`${TAG} browser open attempted`, JSON.stringify(opened.ok
|
|
114
|
+
? { reason: opened.reason, program: opened.program }
|
|
115
|
+
: { reason: opened.reason }));
|
|
116
|
+
if (!opened.ok) {
|
|
117
|
+
writeLine(io.stdout, `(${opened.detail} The link above still works.)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function reportPaired(command, io, paired) {
|
|
41
121
|
if (command.json) {
|
|
42
|
-
writeLine(io.stdout, JSON.stringify(
|
|
43
|
-
return
|
|
122
|
+
writeLine(io.stdout, JSON.stringify(paired.json, null, 2));
|
|
123
|
+
return;
|
|
44
124
|
}
|
|
45
125
|
writeLine(io.stdout, "Tower collector paired.");
|
|
46
|
-
writeLine(io.stdout, `Session: ${
|
|
47
|
-
writeLine(io.stdout, `User: ${
|
|
48
|
-
writeLine(io.stdout, `Device: ${
|
|
49
|
-
|
|
126
|
+
writeLine(io.stdout, `Session: ${paired.sessionFile}`);
|
|
127
|
+
writeLine(io.stdout, `User: ${paired.email}`);
|
|
128
|
+
writeLine(io.stdout, `Device: ${paired.deviceName}`);
|
|
129
|
+
}
|
|
130
|
+
function writeNextStep(command, io, hasRepoPaths) {
|
|
131
|
+
if (command.json)
|
|
132
|
+
return;
|
|
133
|
+
writeLine(io.stdout, hasRepoPaths
|
|
50
134
|
? "Next: run `cockpit start` inside the repo."
|
|
51
135
|
: "Next: run `cockpit onboard` from your repo root so Tower knows which repos to collect.");
|
|
52
|
-
return 0;
|
|
53
136
|
}
|
|
@@ -185,13 +185,21 @@ export function mcpServerMatches(root, config) {
|
|
|
185
185
|
* Claude Code's hook shape, verbatim: `hooks.<Event>` is an ARRAY of matcher
|
|
186
186
|
* groups, each `{ matcher?, hooks: [{ type: "command", command, timeout? }] }`.
|
|
187
187
|
* The matcher is omitted on purpose — all three events want every source.
|
|
188
|
+
*
|
|
189
|
+
* Exported for `memory-install-codex-hooks.ts` (BLI-3729): Codex's own
|
|
190
|
+
* `hooks.json` uses THE SAME three levels, the same event names and the same
|
|
191
|
+
* `type: "command"` handler (learn.chatgpt.com/docs/hooks, read 2026-09-05), so
|
|
192
|
+
* the merge that keeps a person's own hooks and replaces exactly ours has one
|
|
193
|
+
* owner rather than two copies that drift.
|
|
188
194
|
*/
|
|
189
|
-
function
|
|
195
|
+
export function applyHookGroups(root, hookSpecs) {
|
|
190
196
|
const hooks = asRecord(root["hooks"]) ?? {};
|
|
191
|
-
for (const spec of
|
|
197
|
+
for (const spec of hookSpecs) {
|
|
192
198
|
const groups = Array.isArray(hooks[spec.event])
|
|
193
199
|
? hooks[spec.event].slice()
|
|
194
200
|
: [];
|
|
201
|
+
// Somebody else's Stop hook survives untouched; only OUR entries — matched
|
|
202
|
+
// by bin name plus `hook`, wherever the bin ended up — are replaced.
|
|
195
203
|
const survivors = groups.filter((group) => !groupIsOurs(group));
|
|
196
204
|
survivors.push({
|
|
197
205
|
hooks: [
|
|
@@ -205,6 +213,26 @@ function applyHooksAndPermissions(root, config) {
|
|
|
205
213
|
hooks[spec.event] = survivors;
|
|
206
214
|
}
|
|
207
215
|
root["hooks"] = hooks;
|
|
216
|
+
return root;
|
|
217
|
+
}
|
|
218
|
+
/** Exported for `memory-install-codex-hooks.ts` — see `applyHookGroups`. */
|
|
219
|
+
export function hookGroupsMatch(root, hookSpecs) {
|
|
220
|
+
const hooks = asRecord(root["hooks"]);
|
|
221
|
+
if (!hooks)
|
|
222
|
+
return false;
|
|
223
|
+
for (const spec of hookSpecs) {
|
|
224
|
+
const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
|
|
225
|
+
const ours = groups.filter(groupIsOurs);
|
|
226
|
+
if (ours.length !== 1)
|
|
227
|
+
return false;
|
|
228
|
+
const commands = hookCommands(ours[0]);
|
|
229
|
+
if (commands.length !== 1 || commands[0] !== spec.command)
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
function applyHooksAndPermissions(root, config) {
|
|
235
|
+
applyHookGroups(root, config.hooks);
|
|
208
236
|
if (config.permissions_allow.length > 0) {
|
|
209
237
|
const permissions = asRecord(root["permissions"]) ?? {};
|
|
210
238
|
const allow = Array.isArray(permissions["allow"])
|
|
@@ -220,23 +248,14 @@ function applyHooksAndPermissions(root, config) {
|
|
|
220
248
|
return root;
|
|
221
249
|
}
|
|
222
250
|
function hooksAndPermissionsMatch(root, config) {
|
|
223
|
-
|
|
224
|
-
if (!hooks)
|
|
251
|
+
if (!hookGroupsMatch(root, config.hooks))
|
|
225
252
|
return false;
|
|
226
|
-
for (const spec of config.hooks) {
|
|
227
|
-
const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
|
|
228
|
-
const ours = groups.filter(groupIsOurs);
|
|
229
|
-
if (ours.length !== 1)
|
|
230
|
-
return false;
|
|
231
|
-
const commands = hookCommands(ours[0]);
|
|
232
|
-
if (commands.length !== 1 || commands[0] !== spec.command)
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
253
|
const allow = asRecord(root["permissions"])?.["allow"];
|
|
236
254
|
const allowed = Array.isArray(allow) ? allow : [];
|
|
237
255
|
return config.permissions_allow.every((rule) => allowed.includes(rule));
|
|
238
256
|
}
|
|
239
|
-
|
|
257
|
+
/** Exported for `memory-install-codex-hooks.ts` — see `applyHookGroups`. */
|
|
258
|
+
export function ourHookCount(root) {
|
|
240
259
|
const hooks = asRecord(root["hooks"]);
|
|
241
260
|
if (!hooks)
|
|
242
261
|
return 0;
|
|
@@ -262,7 +281,8 @@ function hookCommands(group) {
|
|
|
262
281
|
function failure(target, file, reason, detail) {
|
|
263
282
|
return { target, status: "failed", reason, path: file, detail };
|
|
264
283
|
}
|
|
265
|
-
|
|
284
|
+
/** Exported for `memory-install-codex-hooks.ts` — one JSON-record parser, not two. */
|
|
285
|
+
export function parseJsonRecord(raw) {
|
|
266
286
|
try {
|
|
267
287
|
const parsed = JSON.parse(raw);
|
|
268
288
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CODEX HOOK PARITY (BLI-3729) — recall on every prompt and save on every turn
|
|
3
|
+
* for Codex, the way Claude Code has had since BLI-3580.
|
|
4
|
+
*
|
|
5
|
+
* ## What the platform actually offers, fact-checked
|
|
6
|
+
*
|
|
7
|
+
* Source: OpenAI's own hooks reference, <https://learn.chatgpt.com/docs/hooks>,
|
|
8
|
+
* read 2026-09-05, against `codex-cli 0.144.1`. Four findings, all of which
|
|
9
|
+
* changed the shape of this module:
|
|
10
|
+
*
|
|
11
|
+
* 1. **User-scope hooks exist.** Codex discovers hooks "next to active config
|
|
12
|
+
* layers", and names `~/.codex/hooks.json` as one of the four useful
|
|
13
|
+
* locations. "In untrusted projects, Codex still loads user and system
|
|
14
|
+
* hooks from their own active config layers." So this installs ONE file in
|
|
15
|
+
* the person's home directory — it does NOT walk their collection roots
|
|
16
|
+
* writing a `.codex/hooks.json` into every repo, which would put an
|
|
17
|
+
* untracked file in somebody's working tree and need trusting once per
|
|
18
|
+
* repo forever.
|
|
19
|
+
* 2. **The file shape is Claude Code's, verbatim.** Event → array of matcher
|
|
20
|
+
* groups → `hooks: [{ type: "command", command, timeout }]`, with the same
|
|
21
|
+
* event NAMES: `SessionStart`, `UserPromptSubmit`, `Stop`. That is why the
|
|
22
|
+
* merge here is `applyHookGroups` from `memory-install-claude.ts` and not
|
|
23
|
+
* a second copy: same contract, one owner.
|
|
24
|
+
* 3. **Hooks are on by default now.** `[features] hooks = false` turns them
|
|
25
|
+
* off; `codex_hooks` is a DEPRECATED ALIAS for the same key. The premise
|
|
26
|
+
* that a person must first opt in with `codex_hooks = true` is stale — it
|
|
27
|
+
* was true of the version whose trust rows are still sitting in Edward's
|
|
28
|
+
* `config.toml`, and it is not true of 0.144.1.
|
|
29
|
+
* 4. **Trust is per hook definition, and it is the whole catch.** "Before a
|
|
30
|
+
* non-managed command hook can run, Codex requires you to review and trust
|
|
31
|
+
* the exact hook definition. Codex records trust against the hook's current
|
|
32
|
+
* hash, so new or changed hooks are marked for review and skipped until
|
|
33
|
+
* trusted." `/hooks` in the CLI is that surface. So a machine can be
|
|
34
|
+
* perfectly installed and running nothing, which is exactly the state
|
|
35
|
+
* `needs_trust` exists to name — and it is the one step an intern has to
|
|
36
|
+
* do by hand.
|
|
37
|
+
*
|
|
38
|
+
* ## What is NOT verified
|
|
39
|
+
*
|
|
40
|
+
* Nobody has yet trusted these hooks on a real machine, so the wiring past the
|
|
41
|
+
* write is unproven end to end: the file shape, the event names and the trust
|
|
42
|
+
* mechanism come from the vendor's current documentation and from trust rows
|
|
43
|
+
* this repo's owner's own machine already carries, not from a green run. The
|
|
44
|
+
* receipt is built so that failure is visible rather than assumed away — an
|
|
45
|
+
* untrusted machine reads `needs_trust`, never `ok`.
|
|
46
|
+
*
|
|
47
|
+
* ## The trust read is a FLOOR, deliberately
|
|
48
|
+
*
|
|
49
|
+
* A trust row is `[hooks.state."<source>:<event>:<group>:<hook>"]` carrying
|
|
50
|
+
* `trusted_hash` and sometimes `enabled`. This module cannot recompute Codex's
|
|
51
|
+
* hash, so it cannot prove the stored hash still matches the file we just
|
|
52
|
+
* wrote. It counts rows for OUR hooks file that are not explicitly disabled,
|
|
53
|
+
* and reports `ok` only when there are at least as many as we installed hooks.
|
|
54
|
+
* That can understate (a trusted machine whose row keys we failed to match
|
|
55
|
+
* reads `needs_trust`, and the fix is one `/hooks` visit) and cannot overstate.
|
|
56
|
+
* A gauge that can only be too pessimistic is the right way round for this one.
|
|
57
|
+
*/
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
import { applyHookGroups, applyJsonTarget, hookGroupsMatch, inspectJsonTarget, ourHookCount, } from "./memory-install-claude.js";
|
|
60
|
+
export function codexHooksFile(homeDir) {
|
|
61
|
+
return path.join(homeDir, ".codex", "hooks.json");
|
|
62
|
+
}
|
|
63
|
+
export function codexConfigTomlFile(homeDir) {
|
|
64
|
+
return path.join(homeDir, ".codex", "config.toml");
|
|
65
|
+
}
|
|
66
|
+
export async function installCodexMemoryHooks(options) {
|
|
67
|
+
return applyJsonTarget({
|
|
68
|
+
target: "codex_hooks",
|
|
69
|
+
file: codexHooksFile(options.homeDir),
|
|
70
|
+
options,
|
|
71
|
+
apply: (root) => applyHookGroups(root, options.config.hooks),
|
|
72
|
+
matches: (root) => hookGroupsMatch(root, options.config.hooks),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function inspectCodexMemoryHooks(options) {
|
|
76
|
+
return inspectJsonTarget({
|
|
77
|
+
target: "codex_hooks",
|
|
78
|
+
file: codexHooksFile(options.homeDir),
|
|
79
|
+
io: options.io,
|
|
80
|
+
present: (root) => ourHookCount(root) > 0,
|
|
81
|
+
matches: (root) => hookGroupsMatch(root, options.config.hooks),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Reads the trust state out of `~/.codex/config.toml` WITHOUT parsing the
|
|
86
|
+
* whole file.
|
|
87
|
+
*
|
|
88
|
+
* That file is a person's own configuration — model, approval policy, sandbox,
|
|
89
|
+
* their other servers — and this module has no business understanding all of
|
|
90
|
+
* it. It walks lines, tracks the current table header, and looks at exactly
|
|
91
|
+
* two things: the `hooks` feature key, and `[hooks.state."…"]` headers whose
|
|
92
|
+
* quoted source is our hooks file. Nothing is written here, ever.
|
|
93
|
+
*
|
|
94
|
+
* The trust key's event segment is matched case-INSENSITIVELY and by prefix
|
|
95
|
+
* only: an older Codex spelled the events `user_prompt_submit`, the current
|
|
96
|
+
* one spells them `UserPromptSubmit`, and a reader that insisted on one
|
|
97
|
+
* spelling would report a trusted machine as untrusted the day the vendor
|
|
98
|
+
* changed its mind again.
|
|
99
|
+
*/
|
|
100
|
+
export function readCodexHookTrust(raw, hooksFilePath) {
|
|
101
|
+
if (raw === null) {
|
|
102
|
+
// No config.toml at all. Hooks are on by default, and nothing has been
|
|
103
|
+
// trusted, which is exactly `enabled` + zero rows.
|
|
104
|
+
return { feature: "enabled", trustedRows: 0, disabledRows: 0 };
|
|
105
|
+
}
|
|
106
|
+
const wanted = normalizeTrustSource(hooksFilePath);
|
|
107
|
+
let feature = "enabled";
|
|
108
|
+
let currentTable = "";
|
|
109
|
+
let inOurTrustTable = false;
|
|
110
|
+
let sawTrustedHash = false;
|
|
111
|
+
let sawDisabled = false;
|
|
112
|
+
let trustedRows = 0;
|
|
113
|
+
let disabledRows = 0;
|
|
114
|
+
const flushTrustRow = () => {
|
|
115
|
+
if (!inOurTrustTable || !sawTrustedHash)
|
|
116
|
+
return;
|
|
117
|
+
if (sawDisabled)
|
|
118
|
+
disabledRows += 1;
|
|
119
|
+
else
|
|
120
|
+
trustedRows += 1;
|
|
121
|
+
};
|
|
122
|
+
for (const line of raw.split("\n")) {
|
|
123
|
+
const header = /^\s*\[+([^\]]+)\]+\s*$/u.exec(line);
|
|
124
|
+
if (header) {
|
|
125
|
+
flushTrustRow();
|
|
126
|
+
currentTable = (header[1] ?? "").trim();
|
|
127
|
+
inOurTrustTable = trustTableNamesOurFile(currentTable, wanted);
|
|
128
|
+
sawTrustedHash = false;
|
|
129
|
+
sawDisabled = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const assignment = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$/u.exec(line);
|
|
133
|
+
if (!assignment)
|
|
134
|
+
continue;
|
|
135
|
+
const key = (assignment[1] ?? "").toLowerCase();
|
|
136
|
+
const value = (assignment[2] ?? "").toLowerCase();
|
|
137
|
+
// `[features] hooks = false`, and its deprecated alias `codex_hooks`.
|
|
138
|
+
if (currentTable === "features" && (key === "hooks" || key === "codex_hooks")) {
|
|
139
|
+
if (value.startsWith("false"))
|
|
140
|
+
feature = "disabled";
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!inOurTrustTable)
|
|
144
|
+
continue;
|
|
145
|
+
if (key === "trusted_hash")
|
|
146
|
+
sawTrustedHash = true;
|
|
147
|
+
if (key === "enabled" && value.startsWith("false"))
|
|
148
|
+
sawDisabled = true;
|
|
149
|
+
}
|
|
150
|
+
flushTrustRow();
|
|
151
|
+
return { feature, trustedRows, disabledRows };
|
|
152
|
+
}
|
|
153
|
+
/** Reads the person's `config.toml` through the injected io and judges trust. */
|
|
154
|
+
export async function readCodexHookTrustFromDisk(options) {
|
|
155
|
+
const file = codexConfigTomlFile(options.homeDir);
|
|
156
|
+
try {
|
|
157
|
+
const raw = await options.io.readText(file);
|
|
158
|
+
return readCodexHookTrust(raw, codexHooksFile(options.homeDir));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// An unreadable config is not an untrusted machine, and must not be
|
|
162
|
+
// reported as one — `config_unreadable` says which question could not be
|
|
163
|
+
// answered rather than answering it wrongly.
|
|
164
|
+
return { feature: "config_unreadable", trustedRows: 0, disabledRows: 0 };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `"/Users/x/.codex/hooks.json"` → the same string, case-folded on Windows
|
|
169
|
+
* only and with separators normalized, so the header we scan for compares
|
|
170
|
+
* equal to the path we wrote whichever way either was spelled.
|
|
171
|
+
*/
|
|
172
|
+
function normalizeTrustSource(value) {
|
|
173
|
+
const unquoted = value.replace(/^["']|["']$/gu, "");
|
|
174
|
+
const slashed = unquoted.replace(/\\/gu, "/");
|
|
175
|
+
return process.platform === "win32" ? slashed.toLowerCase() : slashed;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* `hooks.state."<source>:<event>:<group>:<hook>"` — is `<source>` our file?
|
|
179
|
+
*
|
|
180
|
+
* The key is split on the LAST three colons, because a Windows source is
|
|
181
|
+
* `C:/Users/…` and carries one of its own.
|
|
182
|
+
*/
|
|
183
|
+
function trustTableNamesOurFile(tableHeader, wantedSource) {
|
|
184
|
+
const match = /^hooks\.state\.\s*["']([\s\S]+)["']\s*$/u.exec(tableHeader.trim());
|
|
185
|
+
if (!match)
|
|
186
|
+
return false;
|
|
187
|
+
const key = match[1] ?? "";
|
|
188
|
+
const segments = key.split(":");
|
|
189
|
+
if (segments.length < 4)
|
|
190
|
+
return false;
|
|
191
|
+
const source = segments.slice(0, segments.length - 3).join(":");
|
|
192
|
+
return normalizeTrustSource(source) === wantedSource;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* How many hook entries a trusted machine should have rows for. One per event,
|
|
196
|
+
* which is what `applyHookGroups` writes.
|
|
197
|
+
*/
|
|
198
|
+
export function expectedCodexTrustRows(config) {
|
|
199
|
+
return config.hooks.length;
|
|
200
|
+
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Codex half of `cockpit memory install` (BLI-3580).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* THREE targets:
|
|
5
5
|
*
|
|
6
6
|
* ~/.codex/config.toml one table, `[mcp_servers.bli-memory]`.
|
|
7
7
|
* ~/.codex/skills/bli-memory/ the skill that teaches Codex when to
|
|
8
8
|
* recall, save, update and forget — taking
|
|
9
9
|
* over the job the four `supermemory-*`
|
|
10
10
|
* skills did on Edward's machine.
|
|
11
|
+
* ~/.codex/hooks.json the same three lifecycle hooks Claude Code
|
|
12
|
+
* gets, at USER scope (BLI-3729). Owned by
|
|
13
|
+
* `memory-install-codex-hooks.ts`, which
|
|
14
|
+
* carries the vendor citation and the
|
|
15
|
+
* per-hook TRUST story — the one step this
|
|
16
|
+
* installer cannot do for a person.
|
|
11
17
|
*
|
|
12
18
|
* **`config.toml` is somebody's own configuration and is treated that way.**
|
|
13
19
|
* The standing rule for that file is "never change HIS settings"; this respects
|
|
@@ -25,6 +31,7 @@ import path from "node:path";
|
|
|
25
31
|
import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
|
|
26
32
|
import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
|
|
27
33
|
import { memoryCodexSkillFiles } from "./memory-install-skills.js";
|
|
34
|
+
import { installCodexMemoryHooks, inspectCodexMemoryHooks, } from "./memory-install-codex-hooks.js";
|
|
28
35
|
const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
|
|
29
36
|
export function codexConfigFile(homeDir) {
|
|
30
37
|
return path.join(homeDir, ".codex", "config.toml");
|
|
@@ -36,6 +43,9 @@ export async function installCodexMemoryIntegration(options) {
|
|
|
36
43
|
return [
|
|
37
44
|
await applyCodexMcpTable(options),
|
|
38
45
|
await applyCodexSkills(options),
|
|
46
|
+
// BLI-3729. Independent of the two above: a machine with the MCP table and
|
|
47
|
+
// no hooks is a real machine, and it is the common one today.
|
|
48
|
+
await installCodexMemoryHooks(options),
|
|
39
49
|
];
|
|
40
50
|
}
|
|
41
51
|
export async function inspectCodexMemoryIntegration(options) {
|
|
@@ -72,7 +82,7 @@ export async function inspectCodexMemoryIntegration(options) {
|
|
|
72
82
|
path: directory,
|
|
73
83
|
detail: `${current} of ${total} skill files current`,
|
|
74
84
|
};
|
|
75
|
-
return [mcp, skills];
|
|
85
|
+
return [mcp, skills, await inspectCodexMemoryHooks(options)];
|
|
76
86
|
}
|
|
77
87
|
async function applyCodexMcpTable(options) {
|
|
78
88
|
const file = codexConfigFile(options.homeDir);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
|
|
2
|
+
import { resolveMcpBin } from "./mcp-bin-resolve.js";
|
|
3
|
+
import { resolveDashboardUrl } from "./memory-install-report.js";
|
|
4
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
5
|
+
/**
|
|
6
|
+
* `bli-memory-mcp` ships as a DEPENDENCY of `@bli-cockpit/cli` — see
|
|
7
|
+
* `mcp-bin-resolve.ts` (BLI-3706's generalisation of this BLI-3580 lookup,
|
|
8
|
+
* shared with `bli-tower`'s registration) for the full two-step walk and the
|
|
9
|
+
* BLI-3580 story of why it starts at that module's own file rather than
|
|
10
|
+
* `process.argv[1]`.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveMemoryMcpBin(options) {
|
|
13
|
+
return resolveMcpBin({ ...options, binName: MEMORY_MCP_BIN });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* **`no_bin_no_write`.** When `bli-memory-mcp` cannot be resolved, this command
|
|
17
|
+
* writes NOTHING — not the MCP entry, not the hooks, not the Codex table, not
|
|
18
|
+
* the skills.
|
|
19
|
+
*
|
|
20
|
+
* An earlier revision wrote the registration anyway, on the reasoning that a
|
|
21
|
+
* correct shape waiting for the package is better than nothing. It is not:
|
|
22
|
+
* Claude Code RUNS a registered hook. Three hooks pointing at a binary that
|
|
23
|
+
* does not exist would print a hook failure on every SessionStart, every
|
|
24
|
+
* prompt and every Stop, on every intern machine, until the package shipped —
|
|
25
|
+
* a self-inflicted outage in the one surface people look at all day.
|
|
26
|
+
*
|
|
27
|
+
* So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
|
|
28
|
+
* self-heal retries tomorrow, and the first tick after the package lands does
|
|
29
|
+
* the whole registration at once.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveMemoryConfig(command, io, platform, deps) {
|
|
32
|
+
const dashboardUrl = await resolveDashboardUrl(command, deps);
|
|
33
|
+
const found = await resolveMemoryMcpBin({
|
|
34
|
+
env: envWithNodeRuntimeOnPath(io.env ?? process.env),
|
|
35
|
+
platform,
|
|
36
|
+
fileExists: deps.fileExists,
|
|
37
|
+
cliEntryPoint: deps.cliEntryPoint,
|
|
38
|
+
realpath: deps.realpath,
|
|
39
|
+
});
|
|
40
|
+
if (!found) {
|
|
41
|
+
return {
|
|
42
|
+
config: null,
|
|
43
|
+
source: "none",
|
|
44
|
+
binTarget: {
|
|
45
|
+
target: "bin",
|
|
46
|
+
status: "skipped",
|
|
47
|
+
reason: "bin_missing",
|
|
48
|
+
detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (isUnsafeBinPath(found.path)) {
|
|
53
|
+
// A hook command is a shell string by the platform's design. A path that
|
|
54
|
+
// cannot be quoted safely is not escaped cleverly, and it is not swapped
|
|
55
|
+
// for a bare name that may resolve to something else either — the install
|
|
56
|
+
// refuses and says why.
|
|
57
|
+
return {
|
|
58
|
+
config: null,
|
|
59
|
+
source: "none",
|
|
60
|
+
binTarget: {
|
|
61
|
+
target: "bin",
|
|
62
|
+
status: "failed",
|
|
63
|
+
reason: "bin_path_unsafe",
|
|
64
|
+
detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
|
|
65
|
+
},
|
|
66
|
+
bin_source: found.source,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const printed = await printedMemoryConfig(io, found.path);
|
|
70
|
+
if (printed) {
|
|
71
|
+
// The bin prints a BARE command name — it cannot know where it was
|
|
72
|
+
// installed, and it is nested inside the CLI's node_modules rather than on
|
|
73
|
+
// PATH. Path-qualifying it here is what makes the registration runnable at
|
|
74
|
+
// all; see `withResolvedBinPath`.
|
|
75
|
+
const qualified = withResolvedBinPath(printed, {
|
|
76
|
+
binPath: found.path,
|
|
77
|
+
platform,
|
|
78
|
+
dashboardUrl,
|
|
79
|
+
});
|
|
80
|
+
if (qualified) {
|
|
81
|
+
return {
|
|
82
|
+
config: qualified,
|
|
83
|
+
source: "bin",
|
|
84
|
+
binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
|
|
85
|
+
bin_source: found.source,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// A printed hook command this installer cannot re-point. The template is
|
|
89
|
+
// always path-qualified, so it is the safe answer — and the reason says
|
|
90
|
+
// which of the two fallbacks happened.
|
|
91
|
+
return {
|
|
92
|
+
config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
|
|
93
|
+
source: "template",
|
|
94
|
+
binTarget: {
|
|
95
|
+
target: "bin",
|
|
96
|
+
status: "already",
|
|
97
|
+
reason: "bin_printed_config_unqualifiable",
|
|
98
|
+
detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
|
|
99
|
+
},
|
|
100
|
+
bin_source: found.source,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
config: builtinMemoryInstallConfig({
|
|
105
|
+
binPath: found.path,
|
|
106
|
+
platform,
|
|
107
|
+
dashboardUrl,
|
|
108
|
+
}),
|
|
109
|
+
source: "template",
|
|
110
|
+
binTarget: {
|
|
111
|
+
target: "bin",
|
|
112
|
+
status: "already",
|
|
113
|
+
reason: "bin_present_template_used",
|
|
114
|
+
detail: "the bin did not print a usable --print-config; the built-in shape was used",
|
|
115
|
+
},
|
|
116
|
+
bin_source: found.source,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Asks the server for its own shape. Arguments go as an ARRAY — nothing is
|
|
121
|
+
* interpolated into a command line — and on Windows the captured runner routes
|
|
122
|
+
* a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
|
|
123
|
+
*/
|
|
124
|
+
async function printedMemoryConfig(io, binPath) {
|
|
125
|
+
const exec = io.exec;
|
|
126
|
+
if (!exec)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const result = await exec(binPath, ["--print-config", "--claude"]);
|
|
130
|
+
if (result.code !== 0)
|
|
131
|
+
return null;
|
|
132
|
+
return parsePrintedMemoryInstallConfig(result.stdout);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Deliberately silent here: the caller reports
|
|
136
|
+
// `bin_present_template_used`, which is the same information with a name
|
|
137
|
+
// on it, and this path is reached on every machine that has an older bin.
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|