@bli-cockpit/cli 0.2.48 → 0.2.50
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/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +94 -1203
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +58 -1705
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +323 -7
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +604 -0
- package/dist/commands/local-args-tower.js +891 -0
- package/dist/commands/local-args.js +10 -1549
- package/dist/commands/local-help.js +30 -5
- package/dist/commands/local.js +21 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +231 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +378 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +308 -0
- package/dist/commands/sync.js +387 -0
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +76 -6
- package/package.json +2 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { pairLocalCollectorWithAuthFallback, requestPairingAccessToken, resolveInteractiveLoginEmail, } from "./local-auth.js";
|
|
3
|
+
import { ensureLocalCollectorConfig } from "../local-state.js";
|
|
4
|
+
export async function runLogin(command, io) {
|
|
5
|
+
// Standalone `cockpit login` must work on a fresh machine: bootstrap a
|
|
6
|
+
// minimal rootless config when onboard/install has not run yet, instead of
|
|
7
|
+
// failing on the missing config file.
|
|
8
|
+
const { config, paths, created } = await ensureLocalCollectorConfig({
|
|
9
|
+
homeDir: command.homeDir,
|
|
10
|
+
dashboardUrl: command.dashboardUrl,
|
|
11
|
+
});
|
|
12
|
+
const dashboardUrl = command.dashboardUrl ?? config.dashboard_url;
|
|
13
|
+
if (created && !command.json) {
|
|
14
|
+
writeLine(io.stdout, `First login on this machine; wrote collector config: ${paths.config_file}`);
|
|
15
|
+
}
|
|
16
|
+
const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
|
|
17
|
+
const pairingAccessToken = await requestPairingAccessToken({
|
|
18
|
+
dashboardUrl,
|
|
19
|
+
email: claimedOwnerEmail,
|
|
20
|
+
noAuth: command.noAuth,
|
|
21
|
+
json: command.json,
|
|
22
|
+
}, io);
|
|
23
|
+
const result = await pairLocalCollectorWithAuthFallback({
|
|
24
|
+
homeDir: command.homeDir,
|
|
25
|
+
dashboardUrl,
|
|
26
|
+
claimedOwnerEmail,
|
|
27
|
+
deviceName: command.deviceName,
|
|
28
|
+
pollIntervalMs: command.pollIntervalMs,
|
|
29
|
+
timeoutMs: command.timeoutMs,
|
|
30
|
+
pairingAccessToken,
|
|
31
|
+
fetch: io.fetch,
|
|
32
|
+
onPairStarted: command.json
|
|
33
|
+
? undefined
|
|
34
|
+
: (request) => {
|
|
35
|
+
writeLine(io.stdout, "Tower device pairing started.");
|
|
36
|
+
writeLine(io.stdout, `Open: ${request.approve_url}`);
|
|
37
|
+
writeLine(io.stdout, `Code: ${request.user_code}`);
|
|
38
|
+
writeLine(io.stdout, "Waiting for dashboard approval...");
|
|
39
|
+
},
|
|
40
|
+
}, io);
|
|
41
|
+
if (command.json) {
|
|
42
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
writeLine(io.stdout, "Tower collector paired.");
|
|
46
|
+
writeLine(io.stdout, `Session: ${result.session_file}`);
|
|
47
|
+
writeLine(io.stdout, `User: ${result.session.email ?? result.session.auth_subject_id}`);
|
|
48
|
+
writeLine(io.stdout, `Device: ${result.session.device_name ?? result.session.device_id ?? "unknown"}`);
|
|
49
|
+
writeLine(io.stdout, config.default_repo_paths.length > 0
|
|
50
|
+
? "Next: run `cockpit start` inside the repo."
|
|
51
|
+
: "Next: run `cockpit onboard` from your repo root so Tower knows which repos to collect.");
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { errorMessage, writeLine } from "./cli-io.js";
|
|
2
|
+
import { asRecord, callTower, openTower } from "./tower-command.js";
|
|
3
|
+
import { getCollectorRuntimePaths, logoutLocalCollector, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
4
|
+
/**
|
|
5
|
+
* `cockpit logout` removes the local session file. `--revoke` also tells Tower
|
|
6
|
+
* to end the device's token (BLI-3461).
|
|
7
|
+
*
|
|
8
|
+
* Those are two different facts, and the difference matters when a laptop is
|
|
9
|
+
* being handed back: without the revoke the machine stops collecting but the
|
|
10
|
+
* token it held is still one the server would honour. The revoke is attempted
|
|
11
|
+
* FIRST, because it needs the token the local logout is about to delete, and a
|
|
12
|
+
* revoke that fails does not stop the local logout — the person asked to be
|
|
13
|
+
* signed out of this machine, and they get that either way. The outcome of each
|
|
14
|
+
* half is reported separately rather than folded into one "ok".
|
|
15
|
+
*/
|
|
16
|
+
export async function runLogout(command, io) {
|
|
17
|
+
const revoke = command.revoke ? await revokeThisDevice(command, io) : null;
|
|
18
|
+
const result = await logoutLocalCollector({ homeDir: command.homeDir });
|
|
19
|
+
writeLine(io.stderr, `[logout] done ${JSON.stringify({
|
|
20
|
+
local_session: result.removed ? "removed" : "absent",
|
|
21
|
+
revoke: revoke ? revoke.outcome : "not_requested",
|
|
22
|
+
})}`);
|
|
23
|
+
if (command.json) {
|
|
24
|
+
writeLine(io.stdout, JSON.stringify({ ...result, revoke: revoke ?? { outcome: "not_requested" } }, null, 2));
|
|
25
|
+
return revoke && revoke.outcome !== "revoked" ? 1 : 0;
|
|
26
|
+
}
|
|
27
|
+
writeLine(io.stdout, result.removed
|
|
28
|
+
? "Tower collector session removed."
|
|
29
|
+
: "No Tower collector session found.");
|
|
30
|
+
if (revoke) {
|
|
31
|
+
writeLine(io.stdout, revoke.outcome === "revoked"
|
|
32
|
+
? "This device's Tower token is revoked."
|
|
33
|
+
: `This device's Tower token was NOT revoked: ${revoke.detail}`);
|
|
34
|
+
}
|
|
35
|
+
return revoke && revoke.outcome !== "revoked" ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ends this device's own session server-side.
|
|
39
|
+
*
|
|
40
|
+
* The route gates the device arm on `self_revoke_only`, so the device id is
|
|
41
|
+
* read from the paired session rather than accepted as an argument — there is
|
|
42
|
+
* no way to spell "revoke somebody else's laptop" here, and that is deliberate.
|
|
43
|
+
*/
|
|
44
|
+
async function revokeThisDevice(command, io) {
|
|
45
|
+
let tower;
|
|
46
|
+
let deviceId;
|
|
47
|
+
try {
|
|
48
|
+
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
|
|
49
|
+
deviceId = session.device_id;
|
|
50
|
+
tower = await openTower("logout", command, io);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return { outcome: "no_paired_session", detail: errorMessage(error) };
|
|
54
|
+
}
|
|
55
|
+
const result = await callTower(tower, {
|
|
56
|
+
path: `/api/ambient/devices/${encodeURIComponent(deviceId)}/revoke`,
|
|
57
|
+
method: "POST",
|
|
58
|
+
label: "logout revoke",
|
|
59
|
+
body: { reason: "Signed out from the device" },
|
|
60
|
+
});
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
return { outcome: result.reason, detail: result.detail };
|
|
63
|
+
}
|
|
64
|
+
const body = asRecord(result.body);
|
|
65
|
+
return { outcome: "revoked", detail: String(asRecord(body.device).revoked_at ?? "") };
|
|
66
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code half of `cockpit memory install` (BLI-3580).
|
|
3
|
+
*
|
|
4
|
+
* TWO files, because Claude Code reads two:
|
|
5
|
+
*
|
|
6
|
+
* ~/.claude.json user-scope MCP servers, under top-level
|
|
7
|
+
* `mcpServers`. This is where `claude mcp add
|
|
8
|
+
* --scope user` puts a server, and — per the
|
|
9
|
+
* BLI-3580 touchpoint survey — the ONLY place a
|
|
10
|
+
* non-plugin server can be registered for every
|
|
11
|
+
* project on the machine. The Supermemory plugin
|
|
12
|
+
* reached Claude Code through its own marketplace
|
|
13
|
+
* `.mcp.json`; we are not a plugin, so we write a
|
|
14
|
+
* direct user-scope entry instead.
|
|
15
|
+
* ~/.claude/settings.json `hooks` (the three recall/save hooks) and
|
|
16
|
+
* `permissions.allow` (auto-approve for the
|
|
17
|
+
* read-only tool, the documented equivalent of the
|
|
18
|
+
* vendor's PreToolUse regex trick).
|
|
19
|
+
*
|
|
20
|
+
* Three rules govern every write here:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Merge, never clobber.** Both files belong to the person. Every other
|
|
23
|
+
* key, every other MCP server and every other hook comes out untouched, and
|
|
24
|
+
* a file that will not parse is a REFUSAL (`config_unreadable`), never an
|
|
25
|
+
* overwrite — the opposite of what `agent-rules.ts` may do to a markdown
|
|
26
|
+
* file, because losing a person's whole Claude configuration is a different
|
|
27
|
+
* order of damage from losing a rules block.
|
|
28
|
+
* 2. **Idempotent by identity.** Our hook entries are recognised by the bin
|
|
29
|
+
* name (`isMemoryHookCommand`), so a re-run replaces exactly our three and
|
|
30
|
+
* appends nothing.
|
|
31
|
+
* 3. **Read back what the platform stored** (BLI-2541). A write that returned
|
|
32
|
+
* without throwing is not proof. The file is re-read, re-parsed and compared
|
|
33
|
+
* field by field; a difference is `read_back_mismatch` and the target is
|
|
34
|
+
* reported failed.
|
|
35
|
+
*/
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import { isMemoryHookCommand, } from "./memory-install-contract.js";
|
|
38
|
+
export function claudeMcpConfigFile(homeDir) {
|
|
39
|
+
return path.join(homeDir, ".claude.json");
|
|
40
|
+
}
|
|
41
|
+
export function claudeSettingsFile(homeDir) {
|
|
42
|
+
return path.join(homeDir, ".claude", "settings.json");
|
|
43
|
+
}
|
|
44
|
+
export async function installClaudeMemoryIntegration(options) {
|
|
45
|
+
return [
|
|
46
|
+
await applyJsonTarget({
|
|
47
|
+
target: "claude_mcp",
|
|
48
|
+
file: claudeMcpConfigFile(options.homeDir),
|
|
49
|
+
options,
|
|
50
|
+
apply: (root) => applyMcpServer(root, options.config),
|
|
51
|
+
matches: (root) => mcpServerMatches(root, options.config),
|
|
52
|
+
}),
|
|
53
|
+
await applyJsonTarget({
|
|
54
|
+
target: "claude_hooks",
|
|
55
|
+
file: claudeSettingsFile(options.homeDir),
|
|
56
|
+
options,
|
|
57
|
+
apply: (root) => applyHooksAndPermissions(root, options.config),
|
|
58
|
+
matches: (root) => hooksAndPermissionsMatch(root, options.config),
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
export async function inspectClaudeMemoryIntegration(options) {
|
|
63
|
+
return [
|
|
64
|
+
await inspectJsonTarget({
|
|
65
|
+
target: "claude_mcp",
|
|
66
|
+
file: claudeMcpConfigFile(options.homeDir),
|
|
67
|
+
io: options.io,
|
|
68
|
+
present: (root) => readMcpServer(root, options.config.server_id) !== null,
|
|
69
|
+
matches: (root) => mcpServerMatches(root, options.config),
|
|
70
|
+
}),
|
|
71
|
+
await inspectJsonTarget({
|
|
72
|
+
target: "claude_hooks",
|
|
73
|
+
file: claudeSettingsFile(options.homeDir),
|
|
74
|
+
io: options.io,
|
|
75
|
+
present: (root) => ourHookCount(root) > 0,
|
|
76
|
+
matches: (root) => hooksAndPermissionsMatch(root, options.config),
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
async function applyJsonTarget(input) {
|
|
81
|
+
const { file, options } = input;
|
|
82
|
+
const raw = await input.options.io.readText(file);
|
|
83
|
+
let root;
|
|
84
|
+
if (raw === null || !raw.trim()) {
|
|
85
|
+
root = {};
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const parsed = parseJsonRecord(raw);
|
|
89
|
+
if (!parsed) {
|
|
90
|
+
// Refuse rather than replace. A `~/.claude.json` that will not parse
|
|
91
|
+
// still holds the person's project history and their other servers.
|
|
92
|
+
return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
|
|
93
|
+
}
|
|
94
|
+
root = parsed;
|
|
95
|
+
}
|
|
96
|
+
if (input.matches(root)) {
|
|
97
|
+
return { target: input.target, status: "already", reason: "already_current", path: file };
|
|
98
|
+
}
|
|
99
|
+
const next = input.apply(structuredClone(root));
|
|
100
|
+
const serialized = serializeJson(next, raw ?? "");
|
|
101
|
+
if (options.dryRun) {
|
|
102
|
+
return {
|
|
103
|
+
target: input.target,
|
|
104
|
+
status: "would_install",
|
|
105
|
+
reason: "dry_run",
|
|
106
|
+
path: file,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await options.io.writeText(file, serialized);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return failure(input.target, file, "write_failed", error instanceof Error ? error.message : String(error));
|
|
114
|
+
}
|
|
115
|
+
// BLI-2541: what the platform stored, not what we sent it.
|
|
116
|
+
const storedRaw = await options.io.readText(file);
|
|
117
|
+
const stored = storedRaw === null ? null : parseJsonRecord(storedRaw);
|
|
118
|
+
if (!stored || !input.matches(stored)) {
|
|
119
|
+
return failure(input.target, file, "read_back_mismatch", "the file on disk does not contain the entry that was just written");
|
|
120
|
+
}
|
|
121
|
+
return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
|
|
122
|
+
}
|
|
123
|
+
async function inspectJsonTarget(input) {
|
|
124
|
+
const raw = await input.io.readText(input.file);
|
|
125
|
+
if (raw === null || !raw.trim()) {
|
|
126
|
+
return { target: input.target, status: "missing", reason: "file_absent", path: input.file };
|
|
127
|
+
}
|
|
128
|
+
const root = parseJsonRecord(raw);
|
|
129
|
+
if (!root) {
|
|
130
|
+
return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON");
|
|
131
|
+
}
|
|
132
|
+
if (input.matches(root)) {
|
|
133
|
+
return { target: input.target, status: "installed", reason: "already_current", path: input.file };
|
|
134
|
+
}
|
|
135
|
+
const status = input.present(root) ? "mismatch" : "missing";
|
|
136
|
+
return {
|
|
137
|
+
target: input.target,
|
|
138
|
+
status,
|
|
139
|
+
reason: status === "mismatch" ? "entry_differs" : "entry_absent",
|
|
140
|
+
path: input.file,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function applyMcpServer(root, config) {
|
|
144
|
+
const servers = asRecord(root["mcpServers"]) ?? {};
|
|
145
|
+
servers[config.server_id] = {
|
|
146
|
+
command: config.mcp_server.command,
|
|
147
|
+
args: config.mcp_server.args,
|
|
148
|
+
...(Object.keys(config.mcp_server.env).length > 0
|
|
149
|
+
? { env: config.mcp_server.env }
|
|
150
|
+
: {}),
|
|
151
|
+
};
|
|
152
|
+
root["mcpServers"] = servers;
|
|
153
|
+
return root;
|
|
154
|
+
}
|
|
155
|
+
function readMcpServer(root, serverId) {
|
|
156
|
+
const servers = asRecord(root["mcpServers"]);
|
|
157
|
+
if (!servers)
|
|
158
|
+
return null;
|
|
159
|
+
return asRecord(servers[serverId]);
|
|
160
|
+
}
|
|
161
|
+
function mcpServerMatches(root, config) {
|
|
162
|
+
const entry = readMcpServer(root, config.server_id);
|
|
163
|
+
if (!entry)
|
|
164
|
+
return false;
|
|
165
|
+
if (entry["command"] !== config.mcp_server.command)
|
|
166
|
+
return false;
|
|
167
|
+
if (!sameStringArray(entry["args"], config.mcp_server.args))
|
|
168
|
+
return false;
|
|
169
|
+
const env = asRecord(entry["env"]) ?? {};
|
|
170
|
+
for (const [key, value] of Object.entries(config.mcp_server.env)) {
|
|
171
|
+
if (env[key] !== value)
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Claude Code's hook shape, verbatim: `hooks.<Event>` is an ARRAY of matcher
|
|
178
|
+
* groups, each `{ matcher?, hooks: [{ type: "command", command, timeout? }] }`.
|
|
179
|
+
* The matcher is omitted on purpose — all three events want every source.
|
|
180
|
+
*/
|
|
181
|
+
function applyHooksAndPermissions(root, config) {
|
|
182
|
+
const hooks = asRecord(root["hooks"]) ?? {};
|
|
183
|
+
for (const spec of config.hooks) {
|
|
184
|
+
const groups = Array.isArray(hooks[spec.event])
|
|
185
|
+
? hooks[spec.event].slice()
|
|
186
|
+
: [];
|
|
187
|
+
const survivors = groups.filter((group) => !groupIsOurs(group));
|
|
188
|
+
survivors.push({
|
|
189
|
+
hooks: [
|
|
190
|
+
{
|
|
191
|
+
type: "command",
|
|
192
|
+
command: spec.command,
|
|
193
|
+
timeout: spec.timeout_seconds,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
hooks[spec.event] = survivors;
|
|
198
|
+
}
|
|
199
|
+
root["hooks"] = hooks;
|
|
200
|
+
if (config.permissions_allow.length > 0) {
|
|
201
|
+
const permissions = asRecord(root["permissions"]) ?? {};
|
|
202
|
+
const allow = Array.isArray(permissions["allow"])
|
|
203
|
+
? permissions["allow"].filter((entry) => typeof entry === "string")
|
|
204
|
+
: [];
|
|
205
|
+
for (const rule of config.permissions_allow) {
|
|
206
|
+
if (!allow.includes(rule))
|
|
207
|
+
allow.push(rule);
|
|
208
|
+
}
|
|
209
|
+
permissions["allow"] = allow;
|
|
210
|
+
root["permissions"] = permissions;
|
|
211
|
+
}
|
|
212
|
+
return root;
|
|
213
|
+
}
|
|
214
|
+
function hooksAndPermissionsMatch(root, config) {
|
|
215
|
+
const hooks = asRecord(root["hooks"]);
|
|
216
|
+
if (!hooks)
|
|
217
|
+
return false;
|
|
218
|
+
for (const spec of config.hooks) {
|
|
219
|
+
const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
|
|
220
|
+
const ours = groups.filter(groupIsOurs);
|
|
221
|
+
if (ours.length !== 1)
|
|
222
|
+
return false;
|
|
223
|
+
const commands = hookCommands(ours[0]);
|
|
224
|
+
if (commands.length !== 1 || commands[0] !== spec.command)
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const allow = asRecord(root["permissions"])?.["allow"];
|
|
228
|
+
const allowed = Array.isArray(allow) ? allow : [];
|
|
229
|
+
return config.permissions_allow.every((rule) => allowed.includes(rule));
|
|
230
|
+
}
|
|
231
|
+
function ourHookCount(root) {
|
|
232
|
+
const hooks = asRecord(root["hooks"]);
|
|
233
|
+
if (!hooks)
|
|
234
|
+
return 0;
|
|
235
|
+
let count = 0;
|
|
236
|
+
for (const value of Object.values(hooks)) {
|
|
237
|
+
if (!Array.isArray(value))
|
|
238
|
+
continue;
|
|
239
|
+
count += value.filter(groupIsOurs).length;
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
function groupIsOurs(group) {
|
|
244
|
+
return hookCommands(group).some(isMemoryHookCommand);
|
|
245
|
+
}
|
|
246
|
+
function hookCommands(group) {
|
|
247
|
+
const record = asRecord(group);
|
|
248
|
+
if (!record || !Array.isArray(record["hooks"]))
|
|
249
|
+
return [];
|
|
250
|
+
return record["hooks"]
|
|
251
|
+
.map((entry) => asRecord(entry)?.["command"])
|
|
252
|
+
.filter((command) => typeof command === "string");
|
|
253
|
+
}
|
|
254
|
+
function failure(target, file, reason, detail) {
|
|
255
|
+
return { target, status: "failed", reason, path: file, detail };
|
|
256
|
+
}
|
|
257
|
+
function parseJsonRecord(raw) {
|
|
258
|
+
try {
|
|
259
|
+
const parsed = JSON.parse(raw);
|
|
260
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
261
|
+
return null;
|
|
262
|
+
return parsed;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function asRecord(value) {
|
|
269
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
270
|
+
return null;
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
function sameStringArray(value, expected) {
|
|
274
|
+
if (!Array.isArray(value))
|
|
275
|
+
return expected.length === 0;
|
|
276
|
+
return (value.length === expected.length &&
|
|
277
|
+
value.every((entry, index) => entry === expected[index]));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Keeps the file's own indentation. Claude Code rewrites `~/.claude.json`
|
|
281
|
+
* itself, so this is cosmetic — but a diff that reindents a person's whole
|
|
282
|
+
* configuration reads like damage even when it is not.
|
|
283
|
+
*/
|
|
284
|
+
function serializeJson(root, previous) {
|
|
285
|
+
const indent = detectIndent(previous);
|
|
286
|
+
return `${JSON.stringify(root, null, indent)}\n`;
|
|
287
|
+
}
|
|
288
|
+
function detectIndent(previous) {
|
|
289
|
+
const match = previous.match(/\n([ \t]+)"/u);
|
|
290
|
+
if (!match)
|
|
291
|
+
return 2;
|
|
292
|
+
const whitespace = match[1] ?? " ";
|
|
293
|
+
return whitespace.startsWith("\t") ? "\t" : whitespace.length;
|
|
294
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex half of `cockpit memory install` (BLI-3580).
|
|
3
|
+
*
|
|
4
|
+
* TWO targets:
|
|
5
|
+
*
|
|
6
|
+
* ~/.codex/config.toml one table, `[mcp_servers.bli-memory]`.
|
|
7
|
+
* ~/.codex/skills/bli-memory/ the skill that teaches Codex when to
|
|
8
|
+
* recall, save, update and forget — taking
|
|
9
|
+
* over the job the four `supermemory-*`
|
|
10
|
+
* skills did on Edward's machine.
|
|
11
|
+
*
|
|
12
|
+
* **`config.toml` is somebody's own configuration and is treated that way.**
|
|
13
|
+
* The standing rule for that file is "never change HIS settings"; this respects
|
|
14
|
+
* it literally by touching one table and nothing else. The write is a line-span
|
|
15
|
+
* swap (see `memory-install-toml.ts`), not a parse-and-reserialise, so model,
|
|
16
|
+
* approval policy, sandbox settings, comments and every other MCP server come
|
|
17
|
+
* out byte-identical — which a test asserts by comparing the bytes outside our
|
|
18
|
+
* span before and after.
|
|
19
|
+
*
|
|
20
|
+
* The skill files are ours end to end, so they are written whole and compared
|
|
21
|
+
* whole: a person who edited one gets it replaced on the next install, which is
|
|
22
|
+
* the same deal `cockpit agent-rules` offers for its managed block.
|
|
23
|
+
*/
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
|
|
26
|
+
import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
|
|
27
|
+
import { memoryCodexSkillFiles } from "./memory-install-skills.js";
|
|
28
|
+
const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
|
|
29
|
+
export function codexConfigFile(homeDir) {
|
|
30
|
+
return path.join(homeDir, ".codex", "config.toml");
|
|
31
|
+
}
|
|
32
|
+
export function codexSkillDirectory(homeDir) {
|
|
33
|
+
return path.join(homeDir, ".codex", "skills", MEMORY_MCP_SERVER_ID);
|
|
34
|
+
}
|
|
35
|
+
export async function installCodexMemoryIntegration(options) {
|
|
36
|
+
return [
|
|
37
|
+
await applyCodexMcpTable(options),
|
|
38
|
+
await applyCodexSkills(options),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
export async function inspectCodexMemoryIntegration(options) {
|
|
42
|
+
const file = codexConfigFile(options.homeDir);
|
|
43
|
+
const raw = await options.io.readText(file);
|
|
44
|
+
const mcp = raw === null
|
|
45
|
+
? { target: "codex_mcp", status: "missing", reason: "file_absent", path: file }
|
|
46
|
+
: tableMatches(raw, options.config)
|
|
47
|
+
? { target: "codex_mcp", status: "installed", reason: "already_current", path: file }
|
|
48
|
+
: findTomlTableSpan(raw, MEMORY_TOML_PATH)
|
|
49
|
+
? { target: "codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
|
|
50
|
+
: { target: "codex_mcp", status: "missing", reason: "entry_absent", path: file };
|
|
51
|
+
const directory = codexSkillDirectory(options.homeDir);
|
|
52
|
+
const files = memoryCodexSkillFiles();
|
|
53
|
+
let present = 0;
|
|
54
|
+
let current = 0;
|
|
55
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
56
|
+
const stored = await options.io.readText(path.join(directory, relative));
|
|
57
|
+
if (stored === null)
|
|
58
|
+
continue;
|
|
59
|
+
present += 1;
|
|
60
|
+
if (stored === contents)
|
|
61
|
+
current += 1;
|
|
62
|
+
}
|
|
63
|
+
const total = Object.keys(files).length;
|
|
64
|
+
const skills = current === total
|
|
65
|
+
? { target: "codex_skills", status: "installed", reason: "already_current", path: directory }
|
|
66
|
+
: present === 0
|
|
67
|
+
? { target: "codex_skills", status: "missing", reason: "entry_absent", path: directory }
|
|
68
|
+
: {
|
|
69
|
+
target: "codex_skills",
|
|
70
|
+
status: "mismatch",
|
|
71
|
+
reason: "entry_differs",
|
|
72
|
+
path: directory,
|
|
73
|
+
detail: `${current} of ${total} skill files current`,
|
|
74
|
+
};
|
|
75
|
+
return [mcp, skills];
|
|
76
|
+
}
|
|
77
|
+
async function applyCodexMcpTable(options) {
|
|
78
|
+
const file = codexConfigFile(options.homeDir);
|
|
79
|
+
const raw = (await options.io.readText(file)) ?? "";
|
|
80
|
+
if (tableMatches(raw, options.config)) {
|
|
81
|
+
return { target: "codex_mcp", status: "already", reason: "already_current", path: file };
|
|
82
|
+
}
|
|
83
|
+
const next = upsertTomlTable(raw, MEMORY_TOML_PATH, renderMemoryTable(options.config));
|
|
84
|
+
if (options.dryRun) {
|
|
85
|
+
return { target: "codex_mcp", status: "would_install", reason: "dry_run", path: file };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await options.io.writeText(file, next);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
target: "codex_mcp",
|
|
93
|
+
status: "failed",
|
|
94
|
+
reason: "write_failed",
|
|
95
|
+
path: file,
|
|
96
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// BLI-2541: parse the table back out of the stored bytes the way Codex will
|
|
100
|
+
// read it. A successful write is not a correct registration.
|
|
101
|
+
const stored = await options.io.readText(file);
|
|
102
|
+
if (stored === null || !tableMatches(stored, options.config)) {
|
|
103
|
+
return {
|
|
104
|
+
target: "codex_mcp",
|
|
105
|
+
status: "failed",
|
|
106
|
+
reason: "read_back_mismatch",
|
|
107
|
+
path: file,
|
|
108
|
+
detail: "the table on disk does not parse back to the entry that was written",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { target: "codex_mcp", status: "installed", reason: "wrote_entry", path: file };
|
|
112
|
+
}
|
|
113
|
+
async function applyCodexSkills(options) {
|
|
114
|
+
const directory = codexSkillDirectory(options.homeDir);
|
|
115
|
+
const files = memoryCodexSkillFiles();
|
|
116
|
+
const stale = [];
|
|
117
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
118
|
+
const stored = await options.io.readText(path.join(directory, relative));
|
|
119
|
+
if (stored !== contents)
|
|
120
|
+
stale.push(relative);
|
|
121
|
+
}
|
|
122
|
+
if (stale.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
target: "codex_skills",
|
|
125
|
+
status: "already",
|
|
126
|
+
reason: "already_current",
|
|
127
|
+
path: directory,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (options.dryRun) {
|
|
131
|
+
return {
|
|
132
|
+
target: "codex_skills",
|
|
133
|
+
status: "would_install",
|
|
134
|
+
reason: "dry_run",
|
|
135
|
+
path: directory,
|
|
136
|
+
detail: `${stale.length} of ${Object.keys(files).length} skill files would be written`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
for (const relative of stale) {
|
|
140
|
+
const target = path.join(directory, relative);
|
|
141
|
+
try {
|
|
142
|
+
await options.io.writeText(target, files[relative]);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return {
|
|
146
|
+
target: "codex_skills",
|
|
147
|
+
status: "failed",
|
|
148
|
+
reason: "write_failed",
|
|
149
|
+
path: directory,
|
|
150
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const relative of stale) {
|
|
155
|
+
const stored = await options.io.readText(path.join(directory, relative));
|
|
156
|
+
if (stored !== files[relative]) {
|
|
157
|
+
return {
|
|
158
|
+
target: "codex_skills",
|
|
159
|
+
status: "failed",
|
|
160
|
+
reason: "read_back_mismatch",
|
|
161
|
+
path: directory,
|
|
162
|
+
detail: `${relative} does not read back as written`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
target: "codex_skills",
|
|
168
|
+
status: "installed",
|
|
169
|
+
reason: "wrote_entry",
|
|
170
|
+
path: directory,
|
|
171
|
+
detail: `${stale.length} skill file(s) written`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function renderMemoryTable(config) {
|
|
175
|
+
const entries = [
|
|
176
|
+
["command", config.mcp_server.command],
|
|
177
|
+
["args", config.mcp_server.args],
|
|
178
|
+
];
|
|
179
|
+
if (Object.keys(config.mcp_server.env).length > 0) {
|
|
180
|
+
entries.push(["env", config.mcp_server.env]);
|
|
181
|
+
}
|
|
182
|
+
return renderTomlTable(MEMORY_TOML_PATH, entries);
|
|
183
|
+
}
|
|
184
|
+
function tableMatches(raw, config) {
|
|
185
|
+
const table = readTomlTable(raw, MEMORY_TOML_PATH);
|
|
186
|
+
if (!table)
|
|
187
|
+
return false;
|
|
188
|
+
if (table["command"] !== config.mcp_server.command)
|
|
189
|
+
return false;
|
|
190
|
+
const args = table["args"];
|
|
191
|
+
if (!Array.isArray(args))
|
|
192
|
+
return config.mcp_server.args.length === 0;
|
|
193
|
+
if (args.length !== config.mcp_server.args.length)
|
|
194
|
+
return false;
|
|
195
|
+
if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
|
|
196
|
+
return false;
|
|
197
|
+
const env = table["env"];
|
|
198
|
+
for (const [key, value] of Object.entries(config.mcp_server.env)) {
|
|
199
|
+
if (!env || Array.isArray(env) || typeof env === "string")
|
|
200
|
+
return false;
|
|
201
|
+
if (env[key] !== value)
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|