claude-recall 0.24.2 → 0.25.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/README.md +8 -2
- package/dist/cli/claude-recall-cli.js +34 -6
- package/dist/cli/commands/repair.js +493 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +27 -0
package/README.md
CHANGED
|
@@ -291,7 +291,9 @@ tail -20 ~/.claude-recall/hook-logs/memory-stop.log
|
|
|
291
291
|
tail -20 ~/.claude-recall/hook-logs/correction-detector.log
|
|
292
292
|
|
|
293
293
|
# "Something is broken, start fresh"
|
|
294
|
-
claude-recall repair #
|
|
294
|
+
claude-recall repair # Conservative: fix broken hook paths in settings.json (preserves your customizations)
|
|
295
|
+
claude-recall repair --dry-run # Preview what repair would change
|
|
296
|
+
claude-recall repair --reinstall-hooks # Opinionated: rewrite entire hook block from current template
|
|
295
297
|
claude-recall setup --install # Reinstall skills + hooks
|
|
296
298
|
claude-recall mcp cleanup --all # Stop all stale MCP servers
|
|
297
299
|
```
|
|
@@ -305,7 +307,11 @@ claude-recall setup # Show activation instructions
|
|
|
305
307
|
claude-recall setup --install # Install skills + hooks
|
|
306
308
|
claude-recall upgrade # One-shot upgrade: global binary + clear stale MCP servers
|
|
307
309
|
claude-recall status # Installation and system status
|
|
308
|
-
claude-recall repair #
|
|
310
|
+
claude-recall repair # Fix broken claude-recall hook paths (conservative: preserves user customizations)
|
|
311
|
+
claude-recall repair --auto # Non-interactive; apply safe fixes without prompting (used by postinstall)
|
|
312
|
+
claude-recall repair --dry-run # Report what would change without writing
|
|
313
|
+
claude-recall repair --scope user|project|all # Scope the scan: user (~/.claude), project (closest .claude walking up from cwd), all (user + every nested project under ~). Default: all
|
|
314
|
+
claude-recall repair --reinstall-hooks # Opinionated: rewrite entire hook block from current template
|
|
309
315
|
claude-recall hooks check # Verify hook files exist and are valid
|
|
310
316
|
claude-recall hooks test-enforcement # Test if search enforcer hook works
|
|
311
317
|
|
|
@@ -49,6 +49,7 @@ const skill_generator_1 = require("../services/skill-generator");
|
|
|
49
49
|
const mcp_commands_1 = require("./commands/mcp-commands");
|
|
50
50
|
const project_commands_1 = require("./commands/project-commands");
|
|
51
51
|
const hook_commands_1 = require("./commands/hook-commands");
|
|
52
|
+
const repair_1 = require("./commands/repair");
|
|
52
53
|
const program = new commander_1.Command();
|
|
53
54
|
class ClaudeRecallCLI {
|
|
54
55
|
constructor(options) {
|
|
@@ -1289,14 +1290,41 @@ async function main() {
|
|
|
1289
1290
|
}
|
|
1290
1291
|
process.exit(0);
|
|
1291
1292
|
});
|
|
1292
|
-
// Repair command
|
|
1293
|
+
// Repair command — conservative by default: fix broken hook paths without
|
|
1294
|
+
// touching user customizations. --reinstall-hooks (or legacy --force) runs
|
|
1295
|
+
// the opinionated installer that rewrites the entire hook block from template.
|
|
1293
1296
|
program
|
|
1294
1297
|
.command('repair')
|
|
1295
|
-
.description('
|
|
1296
|
-
.option('--
|
|
1297
|
-
.
|
|
1298
|
-
|
|
1299
|
-
|
|
1298
|
+
.description('Fix broken claude-recall hook paths in settings.json (conservative)')
|
|
1299
|
+
.option('--auto', 'Non-interactive; apply safe fixes without prompting')
|
|
1300
|
+
.option('--dry-run', 'Report what would change without writing any files')
|
|
1301
|
+
.option('--reinstall-hooks', 'Rewrite the entire hook block from current template (opinionated)')
|
|
1302
|
+
.option('--scope <scope>', 'user | project | all', 'all')
|
|
1303
|
+
.option('--force', '[deprecated alias for --reinstall-hooks]')
|
|
1304
|
+
.action(async (options) => {
|
|
1305
|
+
if (options.reinstallHooks || options.force) {
|
|
1306
|
+
installSkillsAndHook(true);
|
|
1307
|
+
process.exit(0);
|
|
1308
|
+
}
|
|
1309
|
+
const scope = (options.scope || 'all');
|
|
1310
|
+
if (!['user', 'project', 'all'].includes(scope)) {
|
|
1311
|
+
console.error(`Invalid --scope: ${scope}. Use user, project, or all.`);
|
|
1312
|
+
process.exit(2);
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
const result = await (0, repair_1.runRepair)({
|
|
1316
|
+
auto: !!options.auto,
|
|
1317
|
+
dryRun: !!options.dryRun,
|
|
1318
|
+
scope,
|
|
1319
|
+
});
|
|
1320
|
+
process.exit(result.exitCode);
|
|
1321
|
+
}
|
|
1322
|
+
catch (err) {
|
|
1323
|
+
// Never crash postinstall. Log and exit 0 on unexpected errors so
|
|
1324
|
+
// `npm install` continues. Surface via non-zero only for interactive runs.
|
|
1325
|
+
console.error(`repair: unexpected error: ${err.message}`);
|
|
1326
|
+
process.exit(options.auto ? 0 : 1);
|
|
1327
|
+
}
|
|
1300
1328
|
});
|
|
1301
1329
|
// Check hooks function
|
|
1302
1330
|
function checkHooks() {
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveOnPath = resolveOnPath;
|
|
37
|
+
exports.classifyHook = classifyHook;
|
|
38
|
+
exports.findHomeProjectSettings = findHomeProjectSettings;
|
|
39
|
+
exports.findSettingsFiles = findSettingsFiles;
|
|
40
|
+
exports.scanFile = scanFile;
|
|
41
|
+
exports.applyFixes = applyFixes;
|
|
42
|
+
exports.runRepair = runRepair;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const CLAUDE_RECALL_CLI_RE = /claude[-_]recall[-_]cli(?:\.js)?/i;
|
|
47
|
+
const HOOK_RUN_ID_RE = /hook\s+run\s+(\S+)/i;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a binary name against PATH. POSIX-first; on Windows tries common
|
|
50
|
+
* extensions. Returns the first matching absolute path, or null.
|
|
51
|
+
*/
|
|
52
|
+
function resolveOnPath(binName) {
|
|
53
|
+
const pathEnv = process.env.PATH || '';
|
|
54
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
55
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
56
|
+
if (!dir)
|
|
57
|
+
continue;
|
|
58
|
+
for (const ext of exts) {
|
|
59
|
+
const candidate = path.join(dir, binName + ext);
|
|
60
|
+
try {
|
|
61
|
+
const st = fs.statSync(candidate);
|
|
62
|
+
if (st.isFile())
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// not present — keep looking
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decide whether a hook command belongs to claude-recall and, if so, whether
|
|
74
|
+
* its invocation target actually resolves on disk / on PATH. Pure function —
|
|
75
|
+
* no I/O except for the filesystem check on absolute script paths and the
|
|
76
|
+
* PATH probe (which the caller can stub via `claudeRecallResolver`).
|
|
77
|
+
*/
|
|
78
|
+
function classifyHook(command, claudeRecallResolver) {
|
|
79
|
+
const trimmed = command.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
return { status: 'non-claude-recall' };
|
|
82
|
+
const tokens = trimmed.split(/\s+/);
|
|
83
|
+
const first = tokens[0] || '';
|
|
84
|
+
const base = path.basename(first);
|
|
85
|
+
const looksLikeCR = CLAUDE_RECALL_CLI_RE.test(trimmed) ||
|
|
86
|
+
base === 'claude-recall' ||
|
|
87
|
+
/\bclaude-recall\b/.test(trimmed);
|
|
88
|
+
if (!looksLikeCR)
|
|
89
|
+
return { status: 'non-claude-recall' };
|
|
90
|
+
const hookIdMatch = trimmed.match(HOOK_RUN_ID_RE);
|
|
91
|
+
const hookId = hookIdMatch ? hookIdMatch[1] : null;
|
|
92
|
+
// Case A: `node /abs/path/to/claude-recall-cli.js hook run ...`
|
|
93
|
+
// or `/abs/path/to/node /abs/path/.../claude-recall-cli.js ...`
|
|
94
|
+
if (base === 'node' || /\/node$/.test(first)) {
|
|
95
|
+
const script = tokens[1];
|
|
96
|
+
if (script && path.isAbsolute(script) && CLAUDE_RECALL_CLI_RE.test(script)) {
|
|
97
|
+
if (!fs.existsSync(script)) {
|
|
98
|
+
return { status: 'broken-absolute', scriptPath: script, hookId };
|
|
99
|
+
}
|
|
100
|
+
return { status: 'ok' };
|
|
101
|
+
}
|
|
102
|
+
// Unusual node invocation we don't recognize — don't touch.
|
|
103
|
+
return { status: 'ok' };
|
|
104
|
+
}
|
|
105
|
+
// Case B: `claude-recall hook run ...` (PATH-resolved form — what we rewrite to)
|
|
106
|
+
if (base === 'claude-recall') {
|
|
107
|
+
if (claudeRecallResolver())
|
|
108
|
+
return { status: 'ok' };
|
|
109
|
+
return { status: 'broken-path', binary: 'claude-recall', hookId };
|
|
110
|
+
}
|
|
111
|
+
// Case C: `npx claude-recall ...` — npx resolves at runtime; treat as OK.
|
|
112
|
+
if (base === 'npx')
|
|
113
|
+
return { status: 'ok' };
|
|
114
|
+
// Anything else mentioning claude-recall — leave alone.
|
|
115
|
+
return { status: 'ok' };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Given the closest settings.json-like file path, return the list of paths
|
|
119
|
+
* to scan (settings.json + settings.local.json if present).
|
|
120
|
+
*/
|
|
121
|
+
function pickSiblings(settingsPath) {
|
|
122
|
+
const dir = path.dirname(settingsPath);
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
125
|
+
const p = path.join(dir, name);
|
|
126
|
+
if (fs.existsSync(p))
|
|
127
|
+
out.push(p);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// Directory names that are guaranteed not to contain a project's `.claude/`
|
|
132
|
+
// dir but tend to be huge. Pruning them keeps the home walk fast even on
|
|
133
|
+
// machines with sprawling node_modules/cache trees.
|
|
134
|
+
const HOME_WALK_PRUNE = new Set([
|
|
135
|
+
'node_modules',
|
|
136
|
+
'.git',
|
|
137
|
+
'.npm',
|
|
138
|
+
'.nvm',
|
|
139
|
+
'.cache',
|
|
140
|
+
'.pnpm-store',
|
|
141
|
+
'.yarn',
|
|
142
|
+
'.docker',
|
|
143
|
+
'.local',
|
|
144
|
+
'.cargo',
|
|
145
|
+
'.rustup',
|
|
146
|
+
'.gradle',
|
|
147
|
+
'.m2',
|
|
148
|
+
'.vscode-server',
|
|
149
|
+
'.cursor-server',
|
|
150
|
+
'Library',
|
|
151
|
+
'AppData',
|
|
152
|
+
'dist',
|
|
153
|
+
'build',
|
|
154
|
+
'target',
|
|
155
|
+
'__pycache__',
|
|
156
|
+
'.venv',
|
|
157
|
+
'venv',
|
|
158
|
+
'.tox',
|
|
159
|
+
'.mypy_cache',
|
|
160
|
+
'.pytest_cache',
|
|
161
|
+
'.next',
|
|
162
|
+
'.turbo',
|
|
163
|
+
]);
|
|
164
|
+
const HOME_WALK_MAX_DEPTH = 8;
|
|
165
|
+
/**
|
|
166
|
+
* Walk $HOME (excluding the user-global ~/.claude/ itself) for every nested
|
|
167
|
+
* `.claude/settings.json` and `.claude/settings.local.json`. Conservative on
|
|
168
|
+
* descent — never follows symlinks, prunes well-known bloat dirs, depth-limited.
|
|
169
|
+
* Used by --scope all to find every project under home that may have stale
|
|
170
|
+
* claude-recall hook paths after an install moves.
|
|
171
|
+
*/
|
|
172
|
+
function findHomeProjectSettings(home) {
|
|
173
|
+
const results = [];
|
|
174
|
+
const userClaudeDir = path.join(home, '.claude');
|
|
175
|
+
function walk(dir, depth) {
|
|
176
|
+
if (depth > HOME_WALK_MAX_DEPTH)
|
|
177
|
+
return;
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return; // permission denied, gone, etc. — silently skip
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
// Don't follow symlinks (loop avoidance, also unlikely to host the
|
|
187
|
+
// canonical project root we want to scan).
|
|
188
|
+
if (entry.isSymbolicLink())
|
|
189
|
+
continue;
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
if (HOME_WALK_PRUNE.has(entry.name))
|
|
192
|
+
continue;
|
|
193
|
+
const child = path.join(dir, entry.name);
|
|
194
|
+
if (entry.name === '.claude') {
|
|
195
|
+
// Skip the user-global ~/.claude/ — that's covered by --scope user.
|
|
196
|
+
if (child === userClaudeDir)
|
|
197
|
+
continue;
|
|
198
|
+
for (const f of pickSiblings(path.join(child, 'settings.json'))) {
|
|
199
|
+
if (!results.includes(f))
|
|
200
|
+
results.push(f);
|
|
201
|
+
}
|
|
202
|
+
// Don't descend into .claude/ — settings files live at its root.
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
walk(child, depth + 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
walk(home, 0);
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
function findSettingsFiles(cwd, home, scope) {
|
|
213
|
+
const results = [];
|
|
214
|
+
if (scope === 'user' || scope === 'all') {
|
|
215
|
+
const userDir = path.join(home, '.claude');
|
|
216
|
+
for (const p of pickSiblings(path.join(userDir, 'settings.json'))) {
|
|
217
|
+
if (!results.includes(p))
|
|
218
|
+
results.push(p);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (scope === 'all') {
|
|
222
|
+
// For --scope all we walk the entire home tree to catch every project's
|
|
223
|
+
// .claude/settings.json — important for postinstall, where stale hook paths
|
|
224
|
+
// in ANY project on the machine break Claude Code in that project.
|
|
225
|
+
for (const p of findHomeProjectSettings(home)) {
|
|
226
|
+
if (!results.includes(p))
|
|
227
|
+
results.push(p);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (scope === 'project') {
|
|
231
|
+
// For --scope project we only walk up from cwd looking for the CLOSEST
|
|
232
|
+
// .claude dir (matches Claude Code's own resolution). We don't scan
|
|
233
|
+
// ancestors beyond the first match — those belong to other projects.
|
|
234
|
+
let dir = cwd;
|
|
235
|
+
while (dir !== path.dirname(dir)) {
|
|
236
|
+
const claudeDir = path.join(dir, '.claude');
|
|
237
|
+
const s = path.join(claudeDir, 'settings.json');
|
|
238
|
+
const l = path.join(claudeDir, 'settings.local.json');
|
|
239
|
+
const hasAny = fs.existsSync(s) || fs.existsSync(l);
|
|
240
|
+
if (hasAny) {
|
|
241
|
+
if (fs.existsSync(s) && !results.includes(s))
|
|
242
|
+
results.push(s);
|
|
243
|
+
if (fs.existsSync(l) && !results.includes(l))
|
|
244
|
+
results.push(l);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
dir = path.dirname(dir);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return results;
|
|
251
|
+
}
|
|
252
|
+
function scanFile(settingsPath, claudeRecallResolver) {
|
|
253
|
+
const report = { settingsPath, findings: [] };
|
|
254
|
+
let raw;
|
|
255
|
+
try {
|
|
256
|
+
raw = fs.readFileSync(settingsPath, 'utf8');
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
report.parseError = `cannot read: ${e.message}`;
|
|
260
|
+
return report;
|
|
261
|
+
}
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(raw);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
report.parseError = `invalid JSON: ${e.message}`;
|
|
268
|
+
return report;
|
|
269
|
+
}
|
|
270
|
+
if (typeof parsed.hooksVersion === 'string') {
|
|
271
|
+
report.hooksVersion = parsed.hooksVersion;
|
|
272
|
+
}
|
|
273
|
+
const hooks = parsed.hooks;
|
|
274
|
+
if (!hooks || typeof hooks !== 'object')
|
|
275
|
+
return report;
|
|
276
|
+
const hasCR = () => claudeRecallResolver();
|
|
277
|
+
// Cache resolver result within a single scan to avoid hammering stat().
|
|
278
|
+
let cached;
|
|
279
|
+
const cachingResolver = () => {
|
|
280
|
+
if (cached === undefined)
|
|
281
|
+
cached = hasCR();
|
|
282
|
+
return cached ?? null;
|
|
283
|
+
};
|
|
284
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
285
|
+
if (!Array.isArray(groups))
|
|
286
|
+
continue;
|
|
287
|
+
groups.forEach((group, groupIndex) => {
|
|
288
|
+
if (!group || !Array.isArray(group.hooks))
|
|
289
|
+
return;
|
|
290
|
+
group.hooks.forEach((hook, hookIndex) => {
|
|
291
|
+
if (!hook || typeof hook.command !== 'string')
|
|
292
|
+
return;
|
|
293
|
+
const classification = classifyHook(hook.command, cachingResolver);
|
|
294
|
+
if (classification.status === 'non-claude-recall')
|
|
295
|
+
return;
|
|
296
|
+
const finding = {
|
|
297
|
+
location: { settingsPath, event, groupIndex, hookIndex },
|
|
298
|
+
originalCommand: hook.command,
|
|
299
|
+
classification,
|
|
300
|
+
};
|
|
301
|
+
if (classification.status === 'broken-absolute') {
|
|
302
|
+
const crPath = cachingResolver();
|
|
303
|
+
if (crPath) {
|
|
304
|
+
const id = classification.hookId;
|
|
305
|
+
if (id) {
|
|
306
|
+
finding.proposedCommand = `claude-recall hook run ${id}`;
|
|
307
|
+
}
|
|
308
|
+
// If we can't extract a hook id we don't know what subcommand to
|
|
309
|
+
// invoke. Leave proposedCommand unset — reported as unfixable.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
report.findings.push(finding);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return report;
|
|
317
|
+
}
|
|
318
|
+
function applyFixes(report, opts) {
|
|
319
|
+
if (report.parseError)
|
|
320
|
+
return { changed: false, applied: 0, backupPath: null };
|
|
321
|
+
const fixable = report.findings.filter(f => f.proposedCommand);
|
|
322
|
+
if (fixable.length === 0)
|
|
323
|
+
return { changed: false, applied: 0, backupPath: null };
|
|
324
|
+
const raw = fs.readFileSync(report.settingsPath, 'utf8');
|
|
325
|
+
const parsed = JSON.parse(raw);
|
|
326
|
+
if (!parsed.hooks)
|
|
327
|
+
return { changed: false, applied: 0, backupPath: null };
|
|
328
|
+
let applied = 0;
|
|
329
|
+
for (const f of fixable) {
|
|
330
|
+
const { event, groupIndex, hookIndex } = f.location;
|
|
331
|
+
const group = parsed.hooks[event]?.[groupIndex];
|
|
332
|
+
const entry = group?.hooks?.[hookIndex];
|
|
333
|
+
if (!entry)
|
|
334
|
+
continue;
|
|
335
|
+
// Sanity: only rewrite if the command still matches what we scanned.
|
|
336
|
+
if (entry.command !== f.originalCommand)
|
|
337
|
+
continue;
|
|
338
|
+
entry.command = f.proposedCommand;
|
|
339
|
+
applied++;
|
|
340
|
+
}
|
|
341
|
+
if (applied === 0)
|
|
342
|
+
return { changed: false, applied: 0, backupPath: null };
|
|
343
|
+
if (opts.dryRun) {
|
|
344
|
+
return { changed: true, applied, backupPath: null };
|
|
345
|
+
}
|
|
346
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
347
|
+
const backupPath = `${report.settingsPath}.bak.${ts}`;
|
|
348
|
+
fs.writeFileSync(backupPath, raw);
|
|
349
|
+
fs.writeFileSync(report.settingsPath, JSON.stringify(parsed, null, 2));
|
|
350
|
+
return { changed: true, applied, backupPath };
|
|
351
|
+
}
|
|
352
|
+
function classify(finding) {
|
|
353
|
+
const c = finding.classification;
|
|
354
|
+
if (c.status === 'ok' || c.status === 'non-claude-recall')
|
|
355
|
+
return 'ok';
|
|
356
|
+
return finding.proposedCommand ? 'fixable' : 'unfixable';
|
|
357
|
+
}
|
|
358
|
+
function describe(finding) {
|
|
359
|
+
const c = finding.classification;
|
|
360
|
+
const loc = `${finding.location.event}[${finding.location.groupIndex}].hooks[${finding.location.hookIndex}]`;
|
|
361
|
+
if (c.status === 'broken-absolute') {
|
|
362
|
+
return `${loc} missing script: ${c.scriptPath}`;
|
|
363
|
+
}
|
|
364
|
+
if (c.status === 'broken-path') {
|
|
365
|
+
return `${loc} '${c.binary}' not on PATH`;
|
|
366
|
+
}
|
|
367
|
+
return `${loc} ok`;
|
|
368
|
+
}
|
|
369
|
+
async function runRepair(options = {}) {
|
|
370
|
+
const log = options.logger ?? { log: console.log.bind(console), warn: console.warn.bind(console) };
|
|
371
|
+
const cwd = options.cwd ?? process.cwd();
|
|
372
|
+
const home = options.home ?? os.homedir();
|
|
373
|
+
const scope = options.scope ?? 'all';
|
|
374
|
+
const resolver = options.claudeRecallOnPath ?? (() => resolveOnPath('claude-recall'));
|
|
375
|
+
log.log('\n🩺 Claude Recall repair (conservative)\n');
|
|
376
|
+
const files = findSettingsFiles(cwd, home, scope);
|
|
377
|
+
if (files.length === 0) {
|
|
378
|
+
log.log(`No settings files found (scope: ${scope}).`);
|
|
379
|
+
log.log('Nothing to repair. If you meant to install hooks, run:');
|
|
380
|
+
log.log(' claude-recall setup --install\n');
|
|
381
|
+
return { exitCode: 0, filesScanned: 0, filesModified: 0, fixesApplied: 0, unfixable: 0, reports: [] };
|
|
382
|
+
}
|
|
383
|
+
const reports = [];
|
|
384
|
+
let totalFixable = 0;
|
|
385
|
+
let totalUnfixable = 0;
|
|
386
|
+
let totalOk = 0;
|
|
387
|
+
for (const f of files) {
|
|
388
|
+
const report = scanFile(f, resolver);
|
|
389
|
+
reports.push(report);
|
|
390
|
+
if (report.parseError) {
|
|
391
|
+
log.warn(` ⚠ ${f}: ${report.parseError}`);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
let fixable = 0, unfixable = 0, ok = 0;
|
|
395
|
+
for (const finding of report.findings) {
|
|
396
|
+
const kind = classify(finding);
|
|
397
|
+
if (kind === 'fixable')
|
|
398
|
+
fixable++;
|
|
399
|
+
else if (kind === 'unfixable')
|
|
400
|
+
unfixable++;
|
|
401
|
+
else
|
|
402
|
+
ok++;
|
|
403
|
+
}
|
|
404
|
+
totalFixable += fixable;
|
|
405
|
+
totalUnfixable += unfixable;
|
|
406
|
+
totalOk += ok;
|
|
407
|
+
const versionTag = report.hooksVersion ? ` (hooksVersion: ${report.hooksVersion})` : '';
|
|
408
|
+
log.log(` ${f}${versionTag}`);
|
|
409
|
+
log.log(` ${ok} OK, ${fixable} fixable, ${unfixable} unfixable`);
|
|
410
|
+
for (const finding of report.findings) {
|
|
411
|
+
if (classify(finding) === 'ok')
|
|
412
|
+
continue;
|
|
413
|
+
log.log(` - ${describe(finding)}`);
|
|
414
|
+
if (finding.proposedCommand) {
|
|
415
|
+
log.log(` proposed: ${finding.proposedCommand}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (totalFixable === 0) {
|
|
420
|
+
if (totalUnfixable > 0) {
|
|
421
|
+
log.log(`\n${totalUnfixable} broken claude-recall hook(s) found but no safe fix available.`);
|
|
422
|
+
log.log('Install claude-recall on PATH so repair can rewrite the broken paths:');
|
|
423
|
+
log.log(' npm install -g claude-recall\n');
|
|
424
|
+
// Don't fail postinstall — user's current install was fine until their
|
|
425
|
+
// PATH/hook config drifted; this is diagnostic, not an error.
|
|
426
|
+
return {
|
|
427
|
+
exitCode: 0,
|
|
428
|
+
filesScanned: files.length,
|
|
429
|
+
filesModified: 0,
|
|
430
|
+
fixesApplied: 0,
|
|
431
|
+
unfixable: totalUnfixable,
|
|
432
|
+
reports,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
log.log(`\n✅ All ${totalOk} claude-recall hook(s) look healthy. Nothing to do.\n`);
|
|
436
|
+
return {
|
|
437
|
+
exitCode: 0,
|
|
438
|
+
filesScanned: files.length,
|
|
439
|
+
filesModified: 0,
|
|
440
|
+
fixesApplied: 0,
|
|
441
|
+
unfixable: 0,
|
|
442
|
+
reports,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (!options.auto && !options.dryRun && options.prompt) {
|
|
446
|
+
const proceed = await options.prompt(`\nApply ${totalFixable} fix(es)? [y/N] `);
|
|
447
|
+
if (!proceed) {
|
|
448
|
+
log.log('Aborted. No files changed.\n');
|
|
449
|
+
return {
|
|
450
|
+
exitCode: 0,
|
|
451
|
+
filesScanned: files.length,
|
|
452
|
+
filesModified: 0,
|
|
453
|
+
fixesApplied: 0,
|
|
454
|
+
unfixable: totalUnfixable,
|
|
455
|
+
reports,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
let filesModified = 0;
|
|
460
|
+
let fixesApplied = 0;
|
|
461
|
+
for (const report of reports) {
|
|
462
|
+
const { changed, applied, backupPath } = applyFixes(report, { dryRun: !!options.dryRun });
|
|
463
|
+
if (changed && !options.dryRun) {
|
|
464
|
+
filesModified++;
|
|
465
|
+
fixesApplied += applied;
|
|
466
|
+
log.log(` ✓ ${report.settingsPath}: applied ${applied} fix(es)`);
|
|
467
|
+
if (backupPath)
|
|
468
|
+
log.log(` backup: ${backupPath}`);
|
|
469
|
+
}
|
|
470
|
+
else if (changed && options.dryRun) {
|
|
471
|
+
fixesApplied += applied;
|
|
472
|
+
log.log(` (dry-run) ${report.settingsPath}: would apply ${applied} fix(es)`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (options.dryRun) {
|
|
476
|
+
log.log(`\nDry run complete. ${fixesApplied} fix(es) would be applied across ${reports.filter(r => r.findings.some(f => f.proposedCommand)).length} file(s).\n`);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
log.log(`\n✅ Repaired ${fixesApplied} hook(s) across ${filesModified} file(s).`);
|
|
480
|
+
if (totalUnfixable > 0) {
|
|
481
|
+
log.log(` ${totalUnfixable} issue(s) still need manual attention (see above).`);
|
|
482
|
+
}
|
|
483
|
+
log.log('');
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
exitCode: 0,
|
|
487
|
+
filesScanned: files.length,
|
|
488
|
+
filesModified,
|
|
489
|
+
fixesApplied,
|
|
490
|
+
unfixable: totalUnfixable,
|
|
491
|
+
reports,
|
|
492
|
+
};
|
|
493
|
+
}
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -117,6 +117,33 @@ try {
|
|
|
117
117
|
// `claude-recall setup` invocation by the user, which is conscious and
|
|
118
118
|
// produces a diff the user can see.
|
|
119
119
|
|
|
120
|
+
// Conservative repair on upgrade: fix broken absolute hook paths in
|
|
121
|
+
// ~/.claude/settings.json AND every project's .claude/settings.json under
|
|
122
|
+
// the user's home. Common when node/nvm versions change, or when the package
|
|
123
|
+
// was reinstalled into a different location (e.g. moving from a root-owned
|
|
124
|
+
// global prefix to ~/.npm-global). The --auto --scope all flags mean:
|
|
125
|
+
// • user-global settings AND every nested project settings file are scanned
|
|
126
|
+
// • only commands pointing at MISSING absolute scripts get rewritten
|
|
127
|
+
// • user customizations (timeouts, matchers, sibling hooks) preserved
|
|
128
|
+
// • writes a .bak.<timestamp> before any change
|
|
129
|
+
// • never installs hooks where none exist — satisfies the "don't clobber"
|
|
130
|
+
// rule above
|
|
131
|
+
// Timeout raised because the home walk can touch many directories on
|
|
132
|
+
// larger machines.
|
|
133
|
+
try {
|
|
134
|
+
const cliPath = path.join(__dirname, '..', 'dist', 'cli', 'claude-recall-cli.js');
|
|
135
|
+
if (fs.existsSync(cliPath)) {
|
|
136
|
+
execSync(`node "${cliPath}" repair --auto --scope all`, {
|
|
137
|
+
stdio: 'inherit',
|
|
138
|
+
timeout: 60000
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (repairError) {
|
|
142
|
+
// Non-fatal: postinstall must never fail the npm install. Any repair
|
|
143
|
+
// problem can be fixed manually with `claude-recall repair`.
|
|
144
|
+
console.log('⚠️ Auto-repair skipped (non-fatal):', repairError.message);
|
|
145
|
+
}
|
|
146
|
+
|
|
120
147
|
console.log('\n✅ Installation complete!\n');
|
|
121
148
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
122
149
|
console.log('📌 ACTIVATE CLAUDE RECALL:');
|