engramx 2.1.0 → 3.0.1
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/CHANGELOG.md +110 -0
- package/README.md +114 -17
- package/dist/{aider-context-J557IHIP.js → aider-context-6IDE3R7U.js} +1 -1
- package/dist/{chunk-PEH54LYC.js → chunk-645NBY6L.js} +42 -5
- package/dist/chunk-73IBCRFI.js +215 -0
- package/dist/{chunk-ZVWRIVWQ.js → chunk-B4UOE64J.js} +29 -11
- package/dist/{chunk-XFE6ZANP.js → chunk-FKY6HIT2.js} +1 -1
- package/dist/chunk-RJC6RNXJ.js +1405 -0
- package/dist/{chunk-4XA6ENNL.js → chunk-VLTWBTQ7.js} +14 -15
- package/dist/chunk-ZUC6OXSL.js +178 -0
- package/dist/cli.js +277 -1259
- package/dist/{core-TSXA5XZH.js → core-77F2BVYV.js} +2 -2
- package/dist/{cursor-mdc-VEOFFDVO.js → cursor-mdc-EEO7PYZ3.js} +1 -1
- package/dist/{exporter-AWXS34AS.js → exporter-ZYJ4WM2F.js} +1 -1
- package/dist/{importer-3Q5M6QBL.js → importer-4UWQDH4W.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/mcp-client-ROOJF76V.js +9 -0
- package/dist/mcp-config-QD4NPVXB.js +12 -0
- package/dist/{migrate-UKCO6BUU.js → migrate-KJ5K5NWO.js} +1 -1
- package/dist/{plugin-loader-STTGYIL5.js → plugin-loader-SQQB6V74.js} +69 -23
- package/dist/resolver-H7GXVP73.js +21 -0
- package/dist/serve.js +2 -2
- package/dist/{server-A6MUVKQK.js → server-2ZQKXJ5M.js} +74 -6
- package/dist/{windsurf-rules-RWPKBHRD.js → windsurf-rules-XF7MYF6J.js} +1 -1
- package/dist/{wizard-AOXWMSXW.js → wizard-UH27IO4I.js} +2 -2
- package/package.json +8 -3
- package/scripts/postinstall.mjs +32 -0
- package/scripts/preuninstall.mjs +200 -0
- package/dist/{tuner-KFNNGKG3.js → tuner-Y2YENAZC.js} +3 -3
|
@@ -33,7 +33,7 @@ function buildSection(heading, bullets) {
|
|
|
33
33
|
return [`## ${heading}`, "", ...bullets, ""].join("\n");
|
|
34
34
|
}
|
|
35
35
|
async function generateCursorMdc(projectPath) {
|
|
36
|
-
const { getStore } = await import("./core-
|
|
36
|
+
const { getStore } = await import("./core-77F2BVYV.js");
|
|
37
37
|
const store = await getStore(projectPath);
|
|
38
38
|
try {
|
|
39
39
|
const allNodes = store.getAllNodes();
|
|
@@ -12,7 +12,7 @@ function buildSection(heading, nodes) {
|
|
|
12
12
|
return [`## ${heading}`, "", ...bullets, ""].join("\n");
|
|
13
13
|
}
|
|
14
14
|
async function exportCcs(projectRoot) {
|
|
15
|
-
const { getStore } = await import("./core-
|
|
15
|
+
const { getStore } = await import("./core-77F2BVYV.js");
|
|
16
16
|
const store = await getStore(projectRoot);
|
|
17
17
|
try {
|
|
18
18
|
const allNodes = store.getAllNodes();
|
|
@@ -25,7 +25,7 @@ async function importCcs(projectRoot) {
|
|
|
25
25
|
if (!existsSync(filePath)) {
|
|
26
26
|
return { nodesCreated: 0, sectionsFound: 0 };
|
|
27
27
|
}
|
|
28
|
-
const { getStore } = await import("./core-
|
|
28
|
+
const { getStore } = await import("./core-77F2BVYV.js");
|
|
29
29
|
const store = await getStore(projectRoot);
|
|
30
30
|
try {
|
|
31
31
|
const raw = readFileSync(filePath, "utf-8");
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
generateSummary,
|
|
5
5
|
install,
|
|
6
6
|
uninstall
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-VLTWBTQ7.js";
|
|
8
8
|
import {
|
|
9
9
|
GraphStore,
|
|
10
10
|
SUPPORTED_EXTENSIONS,
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
sliceGraphemeSafe,
|
|
24
24
|
stats,
|
|
25
25
|
truncateGraphemeSafe
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import "./chunk-
|
|
26
|
+
} from "./chunk-B4UOE64J.js";
|
|
27
|
+
import "./chunk-645NBY6L.js";
|
|
28
28
|
export {
|
|
29
29
|
GraphStore,
|
|
30
30
|
SUPPORTED_EXTENSIONS,
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMcpProvider
|
|
3
|
+
} from "./chunk-73IBCRFI.js";
|
|
4
|
+
import {
|
|
5
|
+
validateProviderConfig
|
|
6
|
+
} from "./chunk-ZUC6OXSL.js";
|
|
7
|
+
|
|
1
8
|
// src/providers/plugin-loader.ts
|
|
2
9
|
import { existsSync, readdirSync, mkdirSync } from "fs";
|
|
3
10
|
import { join } from "path";
|
|
@@ -23,34 +30,73 @@ function validatePlugin(mod) {
|
|
|
23
30
|
return { plugin: null, reason: "default export is not an object" };
|
|
24
31
|
}
|
|
25
32
|
const p = candidate;
|
|
26
|
-
|
|
27
|
-
"name"
|
|
28
|
-
"label",
|
|
29
|
-
"tier",
|
|
30
|
-
"tokenBudget",
|
|
31
|
-
"timeoutMs",
|
|
32
|
-
"version",
|
|
33
|
-
"resolve",
|
|
34
|
-
"isAvailable"
|
|
35
|
-
];
|
|
36
|
-
for (const field of required) {
|
|
37
|
-
if (p[field] === void 0 || p[field] === null) {
|
|
38
|
-
return { plugin: null, reason: `missing required field: ${field}` };
|
|
39
|
-
}
|
|
33
|
+
if (typeof p.name !== "string" || p.name.length === 0) {
|
|
34
|
+
return { plugin: null, reason: "name must be a non-empty string" };
|
|
40
35
|
}
|
|
41
|
-
if (typeof p.
|
|
42
|
-
return { plugin: null, reason:
|
|
36
|
+
if (typeof p.label !== "string" || p.label.length === 0) {
|
|
37
|
+
return { plugin: null, reason: `[${p.name}] label must be a non-empty string` };
|
|
43
38
|
}
|
|
44
|
-
if (typeof p.
|
|
45
|
-
return { plugin: null, reason:
|
|
39
|
+
if (typeof p.version !== "string" || p.version.length === 0) {
|
|
40
|
+
return { plugin: null, reason: `[${p.name}] version must be a non-empty string` };
|
|
46
41
|
}
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
const hasMcpConfig = p.mcpConfig !== void 0 && p.mcpConfig !== null;
|
|
43
|
+
const hasResolve = typeof p.resolve === "function";
|
|
44
|
+
if (!hasMcpConfig && !hasResolve) {
|
|
45
|
+
return {
|
|
46
|
+
plugin: null,
|
|
47
|
+
reason: `[${p.name}] plugin needs either a resolve() function or an mcpConfig declaration`
|
|
48
|
+
};
|
|
49
49
|
}
|
|
50
|
-
if (
|
|
51
|
-
|
|
50
|
+
if (hasResolve) {
|
|
51
|
+
const classicRequired = [
|
|
52
|
+
"tier",
|
|
53
|
+
"tokenBudget",
|
|
54
|
+
"timeoutMs",
|
|
55
|
+
"isAvailable"
|
|
56
|
+
];
|
|
57
|
+
for (const field of classicRequired) {
|
|
58
|
+
if (p[field] === void 0 || p[field] === null) {
|
|
59
|
+
return { plugin: null, reason: `[${p.name}] missing required field: ${field}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (typeof p.isAvailable !== "function") {
|
|
63
|
+
return { plugin: null, reason: `[${p.name}] isAvailable must be a function` };
|
|
64
|
+
}
|
|
65
|
+
if (p.tier !== 1 && p.tier !== 2) {
|
|
66
|
+
return { plugin: null, reason: `[${p.name}] tier must be 1 or 2 (got ${String(p.tier)})` };
|
|
67
|
+
}
|
|
68
|
+
return { plugin: candidate, reason: "" };
|
|
69
|
+
}
|
|
70
|
+
const rawConfig = p.mcpConfig;
|
|
71
|
+
const normalizedConfig = {
|
|
72
|
+
name: p.name,
|
|
73
|
+
label: p.label,
|
|
74
|
+
...rawConfig
|
|
75
|
+
};
|
|
76
|
+
const validation = validateProviderConfig(normalizedConfig);
|
|
77
|
+
if (!validation.ok) {
|
|
78
|
+
return {
|
|
79
|
+
plugin: null,
|
|
80
|
+
reason: `[${p.name}] invalid mcpConfig: ${validation.reason}`
|
|
81
|
+
};
|
|
52
82
|
}
|
|
53
|
-
|
|
83
|
+
const mcpProvider = createMcpProvider(
|
|
84
|
+
validation.value
|
|
85
|
+
);
|
|
86
|
+
const merged = {
|
|
87
|
+
name: p.name,
|
|
88
|
+
label: p.label,
|
|
89
|
+
version: p.version,
|
|
90
|
+
description: p.description,
|
|
91
|
+
author: p.author,
|
|
92
|
+
mcpConfig: p.mcpConfig,
|
|
93
|
+
tier: p.tier ?? mcpProvider.tier,
|
|
94
|
+
tokenBudget: p.tokenBudget ?? mcpProvider.tokenBudget,
|
|
95
|
+
timeoutMs: p.timeoutMs ?? mcpProvider.timeoutMs,
|
|
96
|
+
resolve: mcpProvider.resolve.bind(mcpProvider),
|
|
97
|
+
isAvailable: mcpProvider.isAvailable.bind(mcpProvider)
|
|
98
|
+
};
|
|
99
|
+
return { plugin: merged, reason: "" };
|
|
54
100
|
}
|
|
55
101
|
async function loadPlugins(dir) {
|
|
56
102
|
const pluginsDir = dir ?? getPluginsDir();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
_resetAvailabilityCache,
|
|
3
|
+
_resetMcpProvidersCache,
|
|
4
|
+
boostByMistakes,
|
|
5
|
+
enforcePerProviderBudget,
|
|
6
|
+
resolveRichPacket,
|
|
7
|
+
resolveRichPacketStreaming,
|
|
8
|
+
warmAllProviders
|
|
9
|
+
} from "./chunk-RJC6RNXJ.js";
|
|
10
|
+
import "./chunk-22INHMKB.js";
|
|
11
|
+
import "./chunk-B4UOE64J.js";
|
|
12
|
+
import "./chunk-645NBY6L.js";
|
|
13
|
+
export {
|
|
14
|
+
_resetAvailabilityCache,
|
|
15
|
+
_resetMcpProvidersCache,
|
|
16
|
+
boostByMistakes,
|
|
17
|
+
enforcePerProviderBudget,
|
|
18
|
+
resolveRichPacket,
|
|
19
|
+
resolveRichPacketStreaming,
|
|
20
|
+
warmAllProviders
|
|
21
|
+
};
|
package/dist/serve.js
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
query,
|
|
10
10
|
stats,
|
|
11
11
|
truncateGraphemeSafe
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-B4UOE64J.js";
|
|
13
|
+
import "./chunk-645NBY6L.js";
|
|
14
14
|
|
|
15
15
|
// src/serve.ts
|
|
16
16
|
function clampInt(value, defaultValue, min, max) {
|
|
@@ -11,20 +11,20 @@ import {
|
|
|
11
11
|
} from "./chunk-N6PPKOPK.js";
|
|
12
12
|
import {
|
|
13
13
|
summarizeHookLog
|
|
14
|
-
} from "./chunk-
|
|
15
|
-
import {
|
|
16
|
-
getComponentStatus
|
|
17
|
-
} from "./chunk-G4U3QOOW.js";
|
|
14
|
+
} from "./chunk-FKY6HIT2.js";
|
|
18
15
|
import {
|
|
19
16
|
readHookLog
|
|
20
17
|
} from "./chunk-KL6NSPVA.js";
|
|
18
|
+
import {
|
|
19
|
+
getComponentStatus
|
|
20
|
+
} from "./chunk-G4U3QOOW.js";
|
|
21
21
|
import {
|
|
22
22
|
getStore,
|
|
23
23
|
learn,
|
|
24
24
|
query,
|
|
25
25
|
stats
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import "./chunk-
|
|
26
|
+
} from "./chunk-B4UOE64J.js";
|
|
27
|
+
import "./chunk-645NBY6L.js";
|
|
28
28
|
|
|
29
29
|
// src/server/http.ts
|
|
30
30
|
import { createServer } from "http";
|
|
@@ -1093,6 +1093,72 @@ async function handleQuery(req, res, projectRoot) {
|
|
|
1093
1093
|
json(res, 500, { error: "Query failed", detail: String(err) });
|
|
1094
1094
|
}
|
|
1095
1095
|
}
|
|
1096
|
+
async function handleContextStream(req, res, projectRoot) {
|
|
1097
|
+
const url = parseUrl(req);
|
|
1098
|
+
const filePath = url.searchParams.get("file");
|
|
1099
|
+
if (!filePath) {
|
|
1100
|
+
json(res, 400, { error: "Missing required query parameter 'file'" });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const lastEventIdHeader = req.headers["last-event-id"];
|
|
1104
|
+
const resumeAfter = (() => {
|
|
1105
|
+
if (typeof lastEventIdHeader !== "string") return -1;
|
|
1106
|
+
const n = parseInt(lastEventIdHeader, 10);
|
|
1107
|
+
return isNaN(n) ? -1 : n;
|
|
1108
|
+
})();
|
|
1109
|
+
res.writeHead(200, {
|
|
1110
|
+
"Content-Type": "text/event-stream",
|
|
1111
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1112
|
+
Connection: "keep-alive",
|
|
1113
|
+
"X-Accel-Buffering": "no",
|
|
1114
|
+
...corsHeaders(req)
|
|
1115
|
+
});
|
|
1116
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
1117
|
+
const context = {
|
|
1118
|
+
filePath,
|
|
1119
|
+
projectRoot,
|
|
1120
|
+
nodeIds: [],
|
|
1121
|
+
imports: [],
|
|
1122
|
+
hasTests: false,
|
|
1123
|
+
churnRate: 0
|
|
1124
|
+
};
|
|
1125
|
+
const { resolveRichPacketStreaming } = await import("./resolver-H7GXVP73.js");
|
|
1126
|
+
let eventId = 0;
|
|
1127
|
+
let disconnected = false;
|
|
1128
|
+
req.on("close", () => {
|
|
1129
|
+
disconnected = true;
|
|
1130
|
+
});
|
|
1131
|
+
try {
|
|
1132
|
+
for await (const event of resolveRichPacketStreaming(
|
|
1133
|
+
filePath,
|
|
1134
|
+
context
|
|
1135
|
+
)) {
|
|
1136
|
+
if (disconnected) break;
|
|
1137
|
+
if (eventId <= resumeAfter) {
|
|
1138
|
+
eventId++;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const frame = `id: ${eventId}
|
|
1142
|
+
event: ${event.type}
|
|
1143
|
+
data: ${JSON.stringify(
|
|
1144
|
+
event.type === "provider" ? event.result : { providerCount: event.providerCount, durationMs: event.durationMs }
|
|
1145
|
+
)}
|
|
1146
|
+
|
|
1147
|
+
`;
|
|
1148
|
+
try {
|
|
1149
|
+
res.write(frame);
|
|
1150
|
+
} catch {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
eventId++;
|
|
1154
|
+
}
|
|
1155
|
+
} finally {
|
|
1156
|
+
try {
|
|
1157
|
+
res.end();
|
|
1158
|
+
} catch {
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1096
1162
|
async function handleStats(_req, res, projectRoot) {
|
|
1097
1163
|
try {
|
|
1098
1164
|
const result = await stats(projectRoot);
|
|
@@ -1405,6 +1471,8 @@ function createHttpServer(projectRoot, port) {
|
|
|
1405
1471
|
await handleGraphGodNodes(req, res, projectRoot);
|
|
1406
1472
|
} else if (req.method === "GET" && path === "/api/sse") {
|
|
1407
1473
|
handleSSE(req, res, projectRoot);
|
|
1474
|
+
} else if (req.method === "GET" && path === "/context/stream") {
|
|
1475
|
+
await handleContextStream(req, res, projectRoot);
|
|
1408
1476
|
} else if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
|
|
1409
1477
|
res.writeHead(200, {
|
|
1410
1478
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -7,7 +7,7 @@ function buildSection(heading, lines) {
|
|
|
7
7
|
return [`## ${heading}`, "", ...lines, ""].join("\n");
|
|
8
8
|
}
|
|
9
9
|
async function generateWindsurfRules(projectRoot) {
|
|
10
|
-
const { getStore } = await import("./core-
|
|
10
|
+
const { getStore } = await import("./core-77F2BVYV.js");
|
|
11
11
|
const store = await getStore(projectRoot);
|
|
12
12
|
try {
|
|
13
13
|
const allNodes = store.getAllNodes();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engramx",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "The context spine for AI coding agents.
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "The context spine for AI coding agents. 9 built-in providers + mcpConfig plugin contract (wrap any MCP server in 10 lines), generic MCP-client aggregator (stdio), pre-mortem mistake-guard, bi-temporal mistake memory, Anthropic Auto-Memory bridge, SSE streaming context packets, dual-emit AGENTS.md+CLAUDE.md. 90.8% measured real-world token savings (reproducible bench included). Local SQLite, zero cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/NickCirv/engram.git"
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"lint": "tsc --noEmit",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
27
|
"bench": "tsx bench/runner.ts",
|
|
28
|
-
"stress": "tsx bench/stress-test.ts"
|
|
28
|
+
"stress": "tsx bench/stress-test.ts",
|
|
29
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
30
|
+
"preuninstall": "node scripts/preuninstall.mjs"
|
|
29
31
|
},
|
|
30
32
|
"keywords": [
|
|
31
33
|
"structural-code-graph",
|
|
@@ -51,11 +53,14 @@
|
|
|
51
53
|
},
|
|
52
54
|
"files": [
|
|
53
55
|
"dist",
|
|
56
|
+
"scripts/preuninstall.mjs",
|
|
57
|
+
"scripts/postinstall.mjs",
|
|
54
58
|
"LICENSE",
|
|
55
59
|
"README.md",
|
|
56
60
|
"CHANGELOG.md"
|
|
57
61
|
],
|
|
58
62
|
"dependencies": {
|
|
63
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
64
|
"chalk": "^5.6.2",
|
|
60
65
|
"commander": "^14.0.3",
|
|
61
66
|
"sql.js": "^1.14.1",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall — one-time info banner on `npm install -g engramx`.
|
|
4
|
+
* Prints the 'what to do next' hint + the clean-uninstall flow so users
|
|
5
|
+
* don't end up with orphaned hooks (see CHANGELOG v3.0.1 context).
|
|
6
|
+
*
|
|
7
|
+
* Contract:
|
|
8
|
+
* - Never fails the install. Always exit 0.
|
|
9
|
+
* - Respects $CI (quiet in CI environments).
|
|
10
|
+
* - Respects $ENGRAM_NO_POSTINSTALL=1 (ops lever for automated rollouts).
|
|
11
|
+
*/
|
|
12
|
+
if (process.env.CI || process.env.ENGRAM_NO_POSTINSTALL === "1") {
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lines = [
|
|
17
|
+
"",
|
|
18
|
+
" ✅ engramx installed.",
|
|
19
|
+
"",
|
|
20
|
+
" Get started:",
|
|
21
|
+
" cd <your-project> && engram setup",
|
|
22
|
+
"",
|
|
23
|
+
" To remove cleanly later (avoids orphaned Claude Code hooks):",
|
|
24
|
+
" engram uninstall-hook && npm uninstall -g engramx",
|
|
25
|
+
" (npm uninstall -g engramx by itself also works now — preuninstall",
|
|
26
|
+
" hook-cleanup is automatic in 3.0.1+)",
|
|
27
|
+
"",
|
|
28
|
+
" Docs: https://github.com/NickCirv/engram",
|
|
29
|
+
"",
|
|
30
|
+
];
|
|
31
|
+
process.stdout.write(lines.join("\n"));
|
|
32
|
+
process.exit(0);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* preuninstall — cleans up engramx's hook entries in the user's
|
|
4
|
+
* Claude Code settings BEFORE the binary is removed by npm.
|
|
5
|
+
*
|
|
6
|
+
* Why this file exists: without it, `npm uninstall -g engramx` leaves
|
|
7
|
+
* stale hook entries in ~/.claude/settings.json pointing at a binary
|
|
8
|
+
* that no longer exists. Claude Code then fires those hooks on every
|
|
9
|
+
* tool call, exec fails with ENOENT, and user-visible behavior is
|
|
10
|
+
* "Claude Code stopped executing anything." Reported by @freenow82 in
|
|
11
|
+
* 3.0.0's post-launch window — see CHANGELOG v3.0.1.
|
|
12
|
+
*
|
|
13
|
+
* Contract (critical):
|
|
14
|
+
* - NEVER fail the uninstall. We always exit 0. If cleanup hits any
|
|
15
|
+
* problem, we print a one-line hint and move on. The user's goal is
|
|
16
|
+
* to uninstall; we will not be the thing that blocks them.
|
|
17
|
+
* - Self-contained: this script must work even if `engram` is not on
|
|
18
|
+
* PATH at script time (npm's script env usually has it, but we're
|
|
19
|
+
* defensive — edge cases exist).
|
|
20
|
+
* - Scoped conservatively: only touch ~/.claude/settings.json (the
|
|
21
|
+
* USER scope, which is what a global install writes to). Do not
|
|
22
|
+
* walk arbitrary project directories.
|
|
23
|
+
* - Back up before edit. Atomic rename on write.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
|
|
29
|
+
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
30
|
+
|
|
31
|
+
// ── safe helpers ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function parseJsonSafe(text) {
|
|
34
|
+
try {
|
|
35
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A hook entry is "engram-owned" if its command references the engram
|
|
43
|
+
* binary or any engram-related shell. We match conservatively: the
|
|
44
|
+
* substring "engram" (case-insensitive) anywhere in the command string.
|
|
45
|
+
* This is aggressive but safe on uninstall — if a user has a hook
|
|
46
|
+
* unrelated to engramx that happens to contain the word "engram", they
|
|
47
|
+
* wrote that themselves and can re-add it. On uninstall, err toward
|
|
48
|
+
* cleaning more rather than leaving orphans.
|
|
49
|
+
*/
|
|
50
|
+
function isEngramHook(entry) {
|
|
51
|
+
if (!entry || typeof entry !== "object") return false;
|
|
52
|
+
const cmd = typeof entry.command === "string" ? entry.command : "";
|
|
53
|
+
return /engram/i.test(cmd);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk the entire hooks structure. `hooks` may be:
|
|
58
|
+
* hooks[event] = [{ matcher, hooks: [{ command, ... }, ...] }, ...]
|
|
59
|
+
* We rebuild each inner `hooks` array without engram entries, drop
|
|
60
|
+
* matchers whose inner array is now empty, drop event keys whose list
|
|
61
|
+
* is now empty.
|
|
62
|
+
*/
|
|
63
|
+
function stripEngramHooks(settings) {
|
|
64
|
+
const changes = { hooksRemoved: 0, eventsAffected: new Set() };
|
|
65
|
+
if (!settings || typeof settings !== "object") return { settings, changes };
|
|
66
|
+
const { hooks } = settings;
|
|
67
|
+
if (!hooks || typeof hooks !== "object") return { settings, changes };
|
|
68
|
+
|
|
69
|
+
for (const event of Object.keys(hooks)) {
|
|
70
|
+
const list = Array.isArray(hooks[event]) ? hooks[event] : null;
|
|
71
|
+
if (!list) continue;
|
|
72
|
+
const kept = [];
|
|
73
|
+
for (const matcher of list) {
|
|
74
|
+
if (!matcher || typeof matcher !== "object") {
|
|
75
|
+
kept.push(matcher);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const innerHooks = Array.isArray(matcher.hooks) ? matcher.hooks : null;
|
|
79
|
+
if (!innerHooks) {
|
|
80
|
+
kept.push(matcher);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const innerKept = innerHooks.filter((h) => {
|
|
84
|
+
if (isEngramHook(h)) {
|
|
85
|
+
changes.hooksRemoved++;
|
|
86
|
+
changes.eventsAffected.add(event);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
if (innerKept.length > 0) {
|
|
92
|
+
kept.push({ ...matcher, hooks: innerKept });
|
|
93
|
+
} else {
|
|
94
|
+
// entire matcher was engram-only — drop it
|
|
95
|
+
changes.eventsAffected.add(event);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (kept.length > 0) {
|
|
99
|
+
hooks[event] = kept;
|
|
100
|
+
} else {
|
|
101
|
+
delete hooks[event];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If hooks is now empty, drop the key entirely
|
|
106
|
+
if (hooks && Object.keys(hooks).length === 0) {
|
|
107
|
+
delete settings.hooks;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Also drop engram statusLine (HUD)
|
|
111
|
+
if (
|
|
112
|
+
settings.statusLine &&
|
|
113
|
+
typeof settings.statusLine === "object" &&
|
|
114
|
+
typeof settings.statusLine.command === "string" &&
|
|
115
|
+
/engram/i.test(settings.statusLine.command)
|
|
116
|
+
) {
|
|
117
|
+
delete settings.statusLine;
|
|
118
|
+
changes.eventsAffected.add("statusLine");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { settings, changes };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── main ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function main() {
|
|
127
|
+
// If no settings file, nothing to clean. Silent exit.
|
|
128
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = readFileSync(SETTINGS_PATH, "utf-8");
|
|
135
|
+
} catch {
|
|
136
|
+
return; // unreadable — leave alone, user will handle
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parsed = parseJsonSafe(raw);
|
|
140
|
+
if (parsed === null) {
|
|
141
|
+
console.log(
|
|
142
|
+
"[engramx preuninstall] skipped: could not parse " +
|
|
143
|
+
SETTINGS_PATH +
|
|
144
|
+
" (settings unchanged)."
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { settings, changes } = stripEngramHooks(parsed);
|
|
150
|
+
if (changes.hooksRemoved === 0 && changes.eventsAffected.size === 0) {
|
|
151
|
+
return; // nothing to do
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Back up before any write.
|
|
155
|
+
const backupPath = `${SETTINGS_PATH}.engramx-preuninstall-${new Date()
|
|
156
|
+
.toISOString()
|
|
157
|
+
.replace(/[:.]/g, "-")}.bak`;
|
|
158
|
+
try {
|
|
159
|
+
copyFileSync(SETTINGS_PATH, backupPath);
|
|
160
|
+
} catch {
|
|
161
|
+
// if we can't back up, don't write — safety first
|
|
162
|
+
console.log(
|
|
163
|
+
"[engramx preuninstall] skipped: could not write backup next to " +
|
|
164
|
+
SETTINGS_PATH +
|
|
165
|
+
" (settings unchanged)."
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Atomic write via rename.
|
|
171
|
+
try {
|
|
172
|
+
const tmp = `${SETTINGS_PATH}.engramx-preuninstall-tmp`;
|
|
173
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
174
|
+
renameSync(tmp, SETTINGS_PATH);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.log(
|
|
177
|
+
"[engramx preuninstall] skipped: " + String(err) + " (settings unchanged)."
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(
|
|
183
|
+
`[engramx] cleaned up ${changes.hooksRemoved} hook entr${changes.hooksRemoved === 1 ? "y" : "ies"} from ${SETTINGS_PATH}`
|
|
184
|
+
);
|
|
185
|
+
console.log(`[engramx] backup saved: ${backupPath}`);
|
|
186
|
+
console.log(
|
|
187
|
+
"[engramx] if anything looks off, restore with: cp " +
|
|
188
|
+
backupPath +
|
|
189
|
+
" " +
|
|
190
|
+
SETTINGS_PATH
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
main();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// HARD REQUIREMENT: never fail uninstall. Swallow anything.
|
|
198
|
+
console.log("[engramx preuninstall] error (ignored): " + String(err));
|
|
199
|
+
}
|
|
200
|
+
process.exit(0);
|