@bli-cockpit/cli 0.2.58 → 0.2.60
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 +1 -1
- package/dist/commands/browser-open.js +88 -0
- package/dist/commands/docs.js +32 -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/jarvis-answer-envelope.js +82 -0
- package/dist/commands/jarvis-render.js +18 -0
- package/dist/commands/jarvis-turn.js +29 -2
- 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-chat.js +5 -0
- package/dist/commands/local-args-tower-docs-msg.js +105 -6
- package/dist/commands/local-args-tower-search.js +50 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +28 -13
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +655 -0
- package/dist/commands/local-help.js +11 -582
- package/dist/commands/local.js +3 -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-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +25 -1
- package/dist/commands/memory-install.js +76 -2
- package/dist/commands/msg.js +85 -2
- 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 +6 -0
- package/dist/commands/ops.js +59 -2
- 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/commands/tower-mcp-install.js +4 -2
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- package/package.json +4 -4
package/dist/commands/local.js
CHANGED
|
@@ -36,6 +36,7 @@ import { runDocs } from "./docs.js";
|
|
|
36
36
|
import { runMsg } from "./msg.js";
|
|
37
37
|
import { runIssue } from "./issue.js";
|
|
38
38
|
import { runProject } from "./project.js";
|
|
39
|
+
import { runSearch } from "./search.js";
|
|
39
40
|
import { parseLocalArgs } from "./local-args.js";
|
|
40
41
|
// `./local.js` is the published entry point for this command surface: the
|
|
41
42
|
// public CLI's generated root, commands/root.ts, doctor.ts and the test suite
|
|
@@ -143,6 +144,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
143
144
|
return await runIssue(command, io);
|
|
144
145
|
case "project":
|
|
145
146
|
return await runProject(command, io);
|
|
147
|
+
case "search":
|
|
148
|
+
return await runSearch(command, io);
|
|
146
149
|
case "release":
|
|
147
150
|
return await runRelease(command, io);
|
|
148
151
|
}
|
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);
|