elliot-stack 1.0.17 → 1.0.19
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 +17 -0
- package/bin/install.cjs +322 -43
- package/hooks/repo-search-nudge.js +31 -0
- package/package.json +3 -2
- package/skills/estack-read-claude-session-history/SKILL.md +196 -0
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -0
- package/skills/estack-read-claude-session-history/references/modes.md +366 -0
- package/skills/estack-read-claude-session-history/references/recipes.md +237 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -0
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -0
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -0
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -0
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -0
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -0
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1448 -0
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +175 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -0
- package/skills/estack-repo-search/SKILL.md +0 -65
package/README.md
CHANGED
|
@@ -52,6 +52,23 @@ npx elliot-stack@latest
|
|
|
52
52
|
- [Claude Code](https://claude.ai/code) CLI installed
|
|
53
53
|
- Node.js 18+
|
|
54
54
|
|
|
55
|
+
## Contributing
|
|
56
|
+
|
|
57
|
+
External contributions are welcome via pull request. Direct pushes to `main` are blocked — fork the repo, push your changes to a branch, and open a PR. Only the maintainer (Elliot) can merge to `main` and cut releases. This is intentional: `elliot-stack` is a security-sensitive npm package and the release tag can only be pushed by the maintainer.
|
|
58
|
+
|
|
59
|
+
### Testing locally
|
|
60
|
+
|
|
61
|
+
Run the installer straight from your checkout to preview what a real install would do to your `~/.claude/`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
node bin/install.cjs # dry run — previews changes, writes nothing
|
|
65
|
+
node bin/install.cjs --install # actually sync your local edits to ~/.claude/skills/
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run from the repo, the installer **dry-runs by default** so testing never clobbers your live `~/.claude/skills/` install. Pass `--install` once the preview looks right. (`--dry-run` forces a preview even under `npx`.)
|
|
69
|
+
|
|
70
|
+
See [`docs/publishing.md`](docs/publishing.md) for the release flow and security model.
|
|
71
|
+
|
|
55
72
|
## License
|
|
56
73
|
|
|
57
74
|
MIT
|
package/bin/install.cjs
CHANGED
|
@@ -15,10 +15,25 @@ const BACKUP_DIR = path.join(CLAUDE_DIR, '.estack-backup');
|
|
|
15
15
|
const CHECKSUMS_FILE = path.join(CLAUDE_DIR, '.estack-checksums.json');
|
|
16
16
|
const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
|
|
17
17
|
const PACKAGE_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
18
|
+
const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
|
|
19
|
+
const PACKAGE_HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
18
20
|
|
|
19
21
|
// ── Flags ──────────────────────────────────────────────────────────────────
|
|
20
22
|
const SILENT = process.argv.includes('--silent');
|
|
21
23
|
const STARTUP = process.argv.includes('--startup');
|
|
24
|
+
// When run directly from the repo (not via npx/node_modules), default to dry-run
|
|
25
|
+
// so local testing never silently clobbers the live ~/.claude/skills install.
|
|
26
|
+
// Pass --install to actually write files, or --dry-run to force preview mode.
|
|
27
|
+
const IS_LOCAL = !__dirname.includes('node_modules');
|
|
28
|
+
const DRY_RUN = process.argv.includes('--dry-run') ||
|
|
29
|
+
(IS_LOCAL && !process.argv.includes('--install'));
|
|
30
|
+
|
|
31
|
+
// ── Deprecated skills ──────────────────────────────────────────────────────
|
|
32
|
+
// Skills that were renamed or removed. The installer removes these on every
|
|
33
|
+
// run so users don't end up with both the old and new name installed.
|
|
34
|
+
const DEPRECATED_SKILLS = [
|
|
35
|
+
'estack-prompt-builder', // renamed to estack-prompt-builder-coach
|
|
36
|
+
];
|
|
22
37
|
|
|
23
38
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
24
39
|
|
|
@@ -39,15 +54,23 @@ function walkDir(dir, base) {
|
|
|
39
54
|
return files;
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
function computeFileHash(filePath) {
|
|
58
|
+
if (!fs.existsSync(filePath)) return null;
|
|
59
|
+
const hash = crypto.createHash('sha256');
|
|
60
|
+
const raw = fs.readFileSync(filePath);
|
|
61
|
+
hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
|
|
62
|
+
return hash.digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
function computeSkillHash(skillDir) {
|
|
43
66
|
if (!fs.existsSync(skillDir)) return null;
|
|
44
67
|
const hash = crypto.createHash('sha256');
|
|
45
68
|
const files = walkDir(skillDir, skillDir);
|
|
46
69
|
for (const relPath of files) {
|
|
47
70
|
const fullPath = path.join(skillDir, relPath);
|
|
48
|
-
const
|
|
71
|
+
const raw = fs.readFileSync(fullPath);
|
|
49
72
|
hash.update(relPath.replace(/\\/g, '/'));
|
|
50
|
-
hash.update(
|
|
73
|
+
hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
|
|
51
74
|
}
|
|
52
75
|
return hash.digest('hex');
|
|
53
76
|
}
|
|
@@ -66,6 +89,14 @@ function backupSkill(name) {
|
|
|
66
89
|
copyDir(installedDir, path.join(BACKUP_DIR, name));
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
function backupHook(filename) {
|
|
93
|
+
const installedFile = path.join(HOOKS_DIR, filename);
|
|
94
|
+
if (!fs.existsSync(installedFile)) return;
|
|
95
|
+
const dest = path.join(BACKUP_DIR, 'hooks', filename);
|
|
96
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
97
|
+
fs.copyFileSync(installedFile, dest);
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
function promptChar(question) {
|
|
70
101
|
if (!process.stdin.isTTY) {
|
|
71
102
|
// Non-interactive environment — read a line from piped stdin
|
|
@@ -118,9 +149,11 @@ function getSkillDescription(skillDir) {
|
|
|
118
149
|
return '';
|
|
119
150
|
}
|
|
120
151
|
|
|
121
|
-
// ──
|
|
152
|
+
// ── Hook setup ─────────────────────────────────────────────────────────────
|
|
122
153
|
|
|
123
|
-
|
|
154
|
+
// Returns true if the hook was added (or would be added in dryRun), false if
|
|
155
|
+
// it was already configured. In dryRun mode nothing is written to disk.
|
|
156
|
+
function setupStartupHook(dryRun) {
|
|
124
157
|
let settings = {};
|
|
125
158
|
if (fs.existsSync(SETTINGS_FILE)) {
|
|
126
159
|
try {
|
|
@@ -143,6 +176,8 @@ function setupStartupHook() {
|
|
|
143
176
|
}
|
|
144
177
|
}
|
|
145
178
|
|
|
179
|
+
if (dryRun) return true;
|
|
180
|
+
|
|
146
181
|
if (!settings.hooks) settings.hooks = {};
|
|
147
182
|
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
148
183
|
|
|
@@ -164,9 +199,75 @@ function setupStartupHook() {
|
|
|
164
199
|
return true;
|
|
165
200
|
}
|
|
166
201
|
|
|
202
|
+
// Returns true if the hook was added (or would be added in dryRun), false if
|
|
203
|
+
// it was already configured. In dryRun mode nothing is written to disk.
|
|
204
|
+
function setupRepoSearchNudgeHook(dryRun) {
|
|
205
|
+
let settings = {};
|
|
206
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
207
|
+
try {
|
|
208
|
+
settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
209
|
+
} catch (_) {
|
|
210
|
+
settings = {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (settings.hooks && settings.hooks.PostToolUse) {
|
|
215
|
+
for (const group of settings.hooks.PostToolUse) {
|
|
216
|
+
if (group.matcher === 'WebFetch|WebSearch' && group.hooks) {
|
|
217
|
+
for (const hook of group.hooks) {
|
|
218
|
+
if (hook.command && hook.command.includes('repo-search-nudge.js')) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (dryRun) return true;
|
|
227
|
+
|
|
228
|
+
if (!settings.hooks) settings.hooks = {};
|
|
229
|
+
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
230
|
+
|
|
231
|
+
settings.hooks.PostToolUse.push({
|
|
232
|
+
matcher: 'WebFetch|WebSearch',
|
|
233
|
+
hooks: [{
|
|
234
|
+
type: 'command',
|
|
235
|
+
command: `node "${path.join(HOOKS_DIR, 'repo-search-nudge.js').replace(/\\/g, '/')}"`,
|
|
236
|
+
timeout: 5,
|
|
237
|
+
}],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
241
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
167
245
|
// ── Main ────────────────────────────────────────────────────────────────────
|
|
168
246
|
|
|
169
247
|
async function main() {
|
|
248
|
+
// 0. Remove deprecated skills (renamed/deleted from the package)
|
|
249
|
+
if (fs.existsSync(SKILLS_DIR)) {
|
|
250
|
+
const newChecksums0 = fs.existsSync(CHECKSUMS_FILE)
|
|
251
|
+
? (() => { try { return JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8')); } catch (_) { return {}; } })()
|
|
252
|
+
: {};
|
|
253
|
+
let changed = false;
|
|
254
|
+
for (const name of DEPRECATED_SKILLS) {
|
|
255
|
+
const dir = path.join(SKILLS_DIR, name);
|
|
256
|
+
if (fs.existsSync(dir)) {
|
|
257
|
+
if (!DRY_RUN) fs.rmSync(dir, { recursive: true, force: true });
|
|
258
|
+
delete newChecksums0[name];
|
|
259
|
+
changed = true;
|
|
260
|
+
if (!SILENT && !STARTUP) {
|
|
261
|
+
console.log((DRY_RUN ? ' [dry run] Would remove deprecated skill: ' : ' Removed deprecated skill: ') + name);
|
|
262
|
+
}
|
|
263
|
+
} else if (newChecksums0[name]) {
|
|
264
|
+
delete newChecksums0[name];
|
|
265
|
+
changed = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (changed && !DRY_RUN) fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums0, null, 2));
|
|
269
|
+
}
|
|
270
|
+
|
|
170
271
|
// 1. Scan package skills
|
|
171
272
|
if (!fs.existsSync(PACKAGE_SKILLS_DIR)) {
|
|
172
273
|
if (!SILENT && !STARTUP) {
|
|
@@ -191,6 +292,16 @@ async function main() {
|
|
|
191
292
|
packageHashes[name] = computeSkillHash(path.join(PACKAGE_SKILLS_DIR, name));
|
|
192
293
|
}
|
|
193
294
|
|
|
295
|
+
// 2b. Scan package hooks
|
|
296
|
+
const hookFilenames = fs.existsSync(PACKAGE_HOOKS_DIR)
|
|
297
|
+
? fs.readdirSync(PACKAGE_HOOKS_DIR).filter((f) => f.endsWith('.js')).sort()
|
|
298
|
+
: [];
|
|
299
|
+
|
|
300
|
+
const packageHookHashes = {};
|
|
301
|
+
for (const filename of hookFilenames) {
|
|
302
|
+
packageHookHashes[filename] = computeFileHash(path.join(PACKAGE_HOOKS_DIR, filename));
|
|
303
|
+
}
|
|
304
|
+
|
|
194
305
|
// 3. Load existing checksums
|
|
195
306
|
let storedChecksums = {};
|
|
196
307
|
if (fs.existsSync(CHECKSUMS_FILE)) {
|
|
@@ -221,7 +332,7 @@ async function main() {
|
|
|
221
332
|
} else if (currentHash !== storedChecksums[name]) {
|
|
222
333
|
// Stored checksum exists but current doesn't match — user modified it
|
|
223
334
|
modifiedSkills.push(name);
|
|
224
|
-
if (
|
|
335
|
+
if (currentHash !== packageHashes[name]) {
|
|
225
336
|
needsUpdate.push(name);
|
|
226
337
|
}
|
|
227
338
|
} else if (currentHash !== packageHashes[name]) {
|
|
@@ -230,9 +341,36 @@ async function main() {
|
|
|
230
341
|
}
|
|
231
342
|
}
|
|
232
343
|
|
|
344
|
+
// 4b. Detect local modifications and needed updates for hooks
|
|
345
|
+
const modifiedHooks = [];
|
|
346
|
+
const hooksNeedingUpdate = [];
|
|
347
|
+
for (const filename of hookFilenames) {
|
|
348
|
+
const installedFile = path.join(HOOKS_DIR, filename);
|
|
349
|
+
const key = 'hook:' + filename;
|
|
350
|
+
if (!fs.existsSync(installedFile)) {
|
|
351
|
+
hooksNeedingUpdate.push(filename);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const currentHash = computeFileHash(installedFile);
|
|
355
|
+
if (!storedChecksums[key]) {
|
|
356
|
+
if (currentHash !== packageHookHashes[filename]) {
|
|
357
|
+
modifiedHooks.push(filename);
|
|
358
|
+
hooksNeedingUpdate.push(filename);
|
|
359
|
+
}
|
|
360
|
+
} else if (currentHash !== storedChecksums[key]) {
|
|
361
|
+
modifiedHooks.push(filename);
|
|
362
|
+
if (storedChecksums[key] !== packageHookHashes[filename]) {
|
|
363
|
+
hooksNeedingUpdate.push(filename);
|
|
364
|
+
}
|
|
365
|
+
} else if (currentHash !== packageHookHashes[filename]) {
|
|
366
|
+
hooksNeedingUpdate.push(filename);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
233
370
|
// 5. Silent mode — no output at all
|
|
234
371
|
if (SILENT) {
|
|
235
|
-
if (needsUpdate.length === 0 && modifiedSkills.length === 0
|
|
372
|
+
if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
|
|
373
|
+
hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
|
|
236
374
|
process.exit(0);
|
|
237
375
|
}
|
|
238
376
|
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
@@ -243,13 +381,21 @@ async function main() {
|
|
|
243
381
|
copyDir(path.join(PACKAGE_SKILLS_DIR, name), path.join(SKILLS_DIR, name));
|
|
244
382
|
newChecksums[name] = packageHashes[name];
|
|
245
383
|
}
|
|
384
|
+
fs.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
385
|
+
for (const filename of hookFilenames) {
|
|
386
|
+
if (modifiedHooks.includes(filename)) continue;
|
|
387
|
+
if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
|
|
388
|
+
fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
|
|
389
|
+
newChecksums['hook:' + filename] = packageHookHashes[filename];
|
|
390
|
+
}
|
|
246
391
|
fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
|
|
247
392
|
process.exit(0);
|
|
248
393
|
}
|
|
249
394
|
|
|
250
395
|
// 6. Startup mode — non-interactive, backup + merge context for Claude Code
|
|
251
396
|
if (STARTUP) {
|
|
252
|
-
if (needsUpdate.length === 0 && modifiedSkills.length === 0
|
|
397
|
+
if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
|
|
398
|
+
hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
|
|
253
399
|
process.exit(0);
|
|
254
400
|
}
|
|
255
401
|
|
|
@@ -273,6 +419,27 @@ async function main() {
|
|
|
273
419
|
updated.push(name);
|
|
274
420
|
}
|
|
275
421
|
|
|
422
|
+
// Install hooks
|
|
423
|
+
fs.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
424
|
+
const updatedHooks = [];
|
|
425
|
+
const mergeNeededHooks = [];
|
|
426
|
+
|
|
427
|
+
for (const filename of hookFilenames) {
|
|
428
|
+
if (modifiedHooks.includes(filename)) {
|
|
429
|
+
backupHook(filename);
|
|
430
|
+
fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
|
|
431
|
+
newChecksums['hook:' + filename] = packageHookHashes[filename];
|
|
432
|
+
mergeNeededHooks.push(filename);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
|
|
436
|
+
fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
|
|
437
|
+
newChecksums['hook:' + filename] = packageHookHashes[filename];
|
|
438
|
+
updatedHooks.push(filename);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
setupRepoSearchNudgeHook();
|
|
442
|
+
|
|
276
443
|
fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
|
|
277
444
|
|
|
278
445
|
// Build output for Claude Code
|
|
@@ -283,6 +450,10 @@ async function main() {
|
|
|
283
450
|
msgParts.push('estack: updated ' + updated.join(', '));
|
|
284
451
|
}
|
|
285
452
|
|
|
453
|
+
if (updatedHooks.length > 0) {
|
|
454
|
+
msgParts.push('estack: updated hooks ' + updatedHooks.join(', '));
|
|
455
|
+
}
|
|
456
|
+
|
|
286
457
|
if (mergeNeeded.length > 0) {
|
|
287
458
|
const backupPath = BACKUP_DIR.replace(HOME, '~');
|
|
288
459
|
msgParts.push(
|
|
@@ -299,6 +470,21 @@ async function main() {
|
|
|
299
470
|
'identify the user\'s changes, and apply them to the new version where compatible.';
|
|
300
471
|
}
|
|
301
472
|
|
|
473
|
+
if (mergeNeededHooks.length > 0) {
|
|
474
|
+
const backupPath = BACKUP_DIR.replace(HOME, '~');
|
|
475
|
+
msgParts.push(
|
|
476
|
+
'estack: updated hooks ' + mergeNeededHooks.join(', ') +
|
|
477
|
+
' (local changes backed up to ' + backupPath + '/hooks/)'
|
|
478
|
+
);
|
|
479
|
+
const existingContext = output.additionalContext ? output.additionalContext + ' ' : '';
|
|
480
|
+
output.additionalContext =
|
|
481
|
+
existingContext +
|
|
482
|
+
'estack hooks were updated but the user had local modifications to: ' +
|
|
483
|
+
mergeNeededHooks.join(', ') + '. ' +
|
|
484
|
+
'Their previous versions are saved at ' + path.join(BACKUP_DIR, 'hooks') + '. ' +
|
|
485
|
+
'The new upstream versions are now installed at ' + HOOKS_DIR + '.';
|
|
486
|
+
}
|
|
487
|
+
|
|
302
488
|
if (msgParts.length > 0) {
|
|
303
489
|
output.systemMessage = msgParts.join('\n');
|
|
304
490
|
}
|
|
@@ -312,37 +498,53 @@ async function main() {
|
|
|
312
498
|
// 7. Interactive mode — prompt if modifications detected
|
|
313
499
|
let modifiedAction = null; // 'overwrite', 'skip', or 'merge'
|
|
314
500
|
|
|
315
|
-
if (modifiedSkills.length > 0) {
|
|
316
|
-
console.log('\nThe following
|
|
317
|
-
|
|
318
|
-
console.log('
|
|
501
|
+
if (modifiedSkills.length > 0 || modifiedHooks.length > 0) {
|
|
502
|
+
console.log('\nThe following items have been modified locally:');
|
|
503
|
+
if (modifiedSkills.length > 0) {
|
|
504
|
+
console.log(' Skills:');
|
|
505
|
+
for (const name of modifiedSkills) {
|
|
506
|
+
console.log(' - ' + name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (modifiedHooks.length > 0) {
|
|
510
|
+
console.log(' Hooks:');
|
|
511
|
+
for (const filename of modifiedHooks) {
|
|
512
|
+
console.log(' - ' + filename);
|
|
513
|
+
}
|
|
319
514
|
}
|
|
320
|
-
console.log('\nChoose an action:');
|
|
321
|
-
console.log(' [o] Overwrite all (replace with latest)');
|
|
322
|
-
console.log(' [s] Skip all (keep local versions)');
|
|
323
|
-
console.log(' [m] Merge (backup local, install new, merge in Claude Code)');
|
|
324
|
-
console.log(' [a] Abort (cancel installation)');
|
|
325
|
-
console.log('');
|
|
326
|
-
|
|
327
|
-
const answer = await promptChar('Your choice (o/s/m/a): ');
|
|
328
515
|
|
|
329
|
-
if (
|
|
330
|
-
console.log('
|
|
331
|
-
|
|
332
|
-
} else if (answer === 's') {
|
|
333
|
-
modifiedAction = 'skip';
|
|
334
|
-
} else if (answer === 'm') {
|
|
335
|
-
modifiedAction = 'merge';
|
|
336
|
-
} else if (answer === 'o') {
|
|
516
|
+
if (DRY_RUN) {
|
|
517
|
+
console.log('\n[dry run] Would prompt: overwrite / skip / merge / abort');
|
|
518
|
+
console.log('[dry run] Showing what would happen with default overwrite...');
|
|
337
519
|
modifiedAction = 'overwrite';
|
|
338
520
|
} else {
|
|
339
|
-
console.log('
|
|
340
|
-
|
|
521
|
+
console.log('\nChoose an action:');
|
|
522
|
+
console.log(' [o] Overwrite all (replace with latest)');
|
|
523
|
+
console.log(' [s] Skip all (keep local versions)');
|
|
524
|
+
console.log(' [m] Merge (backup local, install new, merge in Claude Code)');
|
|
525
|
+
console.log(' [a] Abort (cancel installation)');
|
|
526
|
+
console.log('');
|
|
527
|
+
|
|
528
|
+
const answer = await promptChar('Your choice (o/s/m/a): ');
|
|
529
|
+
|
|
530
|
+
if (answer === 'a') {
|
|
531
|
+
console.log('Installation aborted.');
|
|
532
|
+
process.exit(0);
|
|
533
|
+
} else if (answer === 's') {
|
|
534
|
+
modifiedAction = 'skip';
|
|
535
|
+
} else if (answer === 'm') {
|
|
536
|
+
modifiedAction = 'merge';
|
|
537
|
+
} else if (answer === 'o') {
|
|
538
|
+
modifiedAction = 'overwrite';
|
|
539
|
+
} else {
|
|
540
|
+
console.log('Invalid choice. Installation aborted.');
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
341
543
|
}
|
|
342
544
|
}
|
|
343
545
|
|
|
344
546
|
// 8. Install skills
|
|
345
|
-
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
547
|
+
if (!DRY_RUN) fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
346
548
|
const newChecksums = Object.assign({}, storedChecksums);
|
|
347
549
|
let installedCount = 0;
|
|
348
550
|
const mergedSkills = [];
|
|
@@ -356,30 +558,85 @@ async function main() {
|
|
|
356
558
|
continue;
|
|
357
559
|
}
|
|
358
560
|
if (modifiedAction === 'merge') {
|
|
359
|
-
backupSkill(name);
|
|
561
|
+
if (!DRY_RUN) backupSkill(name);
|
|
360
562
|
mergedSkills.push(name);
|
|
361
|
-
console.log(' Backed up ' + name + ' → ~/.claude/.estack-backup/' + name);
|
|
563
|
+
console.log((DRY_RUN ? ' [dry run] Would back up ' : ' Backed up ') + name + ' → ~/.claude/.estack-backup/' + name);
|
|
362
564
|
}
|
|
363
565
|
// overwrite or merge — fall through to install
|
|
364
566
|
} else if (!needsUpdate.includes(name) && fs.existsSync(path.join(SKILLS_DIR, name))) {
|
|
365
567
|
// Already installed and up-to-date
|
|
568
|
+
if (DRY_RUN) console.log(' [dry run] Up to date (no change): ' + name);
|
|
366
569
|
continue;
|
|
367
570
|
}
|
|
368
|
-
|
|
571
|
+
const isUpdate = fs.existsSync(path.join(SKILLS_DIR, name));
|
|
572
|
+
if (!DRY_RUN) copyDir(path.join(PACKAGE_SKILLS_DIR, name), path.join(SKILLS_DIR, name));
|
|
369
573
|
newChecksums[name] = packageHashes[name];
|
|
370
574
|
installedCount++;
|
|
371
|
-
|
|
575
|
+
if (DRY_RUN) {
|
|
576
|
+
console.log(' [dry run] Would ' + (isUpdate ? 'update ' : 'install ') + name);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(' Installed ' + name);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 8b. Install hooks
|
|
583
|
+
if (!DRY_RUN) fs.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
584
|
+
let installedHookCount = 0;
|
|
585
|
+
const mergedHooks = [];
|
|
586
|
+
|
|
587
|
+
for (const filename of hookFilenames) {
|
|
588
|
+
if (modifiedHooks.includes(filename)) {
|
|
589
|
+
if (modifiedAction === 'skip') {
|
|
590
|
+
console.log(' Skipped hook ' + filename + ' (local modifications preserved)');
|
|
591
|
+
const currentHash = computeFileHash(path.join(HOOKS_DIR, filename));
|
|
592
|
+
if (currentHash) newChecksums['hook:' + filename] = currentHash;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (modifiedAction === 'merge') {
|
|
596
|
+
if (!DRY_RUN) backupHook(filename);
|
|
597
|
+
mergedHooks.push(filename);
|
|
598
|
+
console.log((DRY_RUN ? ' [dry run] Would back up hook ' : ' Backed up hook ') + filename + ' → ~/.claude/.estack-backup/hooks/' + filename);
|
|
599
|
+
}
|
|
600
|
+
// overwrite or merge — fall through to install
|
|
601
|
+
} else if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) {
|
|
602
|
+
// Already installed and up-to-date
|
|
603
|
+
if (DRY_RUN) console.log(' [dry run] Up to date (no change): hook ' + filename);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const isHookUpdate = fs.existsSync(path.join(HOOKS_DIR, filename));
|
|
607
|
+
if (!DRY_RUN) fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
|
|
608
|
+
newChecksums['hook:' + filename] = packageHookHashes[filename];
|
|
609
|
+
installedHookCount++;
|
|
610
|
+
if (DRY_RUN) {
|
|
611
|
+
console.log(' [dry run] Would ' + (isHookUpdate ? 'update hook ' : 'install hook ') + filename);
|
|
612
|
+
} else {
|
|
613
|
+
console.log(' Installed hook ' + filename);
|
|
614
|
+
}
|
|
372
615
|
}
|
|
373
616
|
|
|
374
617
|
// 9. Write checksums
|
|
375
|
-
fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
|
|
618
|
+
if (!DRY_RUN) fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
|
|
376
619
|
|
|
377
|
-
// 10. Setup startup hook
|
|
378
|
-
|
|
620
|
+
// 10. Setup startup hook and repo-search nudge hook
|
|
621
|
+
// In dry-run these inspect settings.json read-only and report would-be action.
|
|
622
|
+
const hookInstalled = setupStartupHook(DRY_RUN);
|
|
623
|
+
const nudgeHookInstalled = setupRepoSearchNudgeHook(DRY_RUN);
|
|
379
624
|
|
|
380
625
|
// 11. Summary output
|
|
381
|
-
|
|
382
|
-
|
|
626
|
+
if (DRY_RUN) {
|
|
627
|
+
console.log('\n[dry run] No files were changed. Run with --install to apply.\n');
|
|
628
|
+
console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.claude/skills/');
|
|
629
|
+
if (installedHookCount > 0) {
|
|
630
|
+
console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.claude/hooks/');
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
console.log('\nestack installed successfully!\n');
|
|
634
|
+
console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.claude/skills/');
|
|
635
|
+
if (installedHookCount > 0) {
|
|
636
|
+
console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' installed to ~/.claude/hooks/');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
console.log('');
|
|
383
640
|
console.log('Skills available:');
|
|
384
641
|
|
|
385
642
|
for (const name of skillNames) {
|
|
@@ -393,12 +650,34 @@ async function main() {
|
|
|
393
650
|
console.log(' "Merge my estack changes from ~/.claude/.estack-backup/"');
|
|
394
651
|
}
|
|
395
652
|
|
|
396
|
-
if (
|
|
397
|
-
console.log('\
|
|
398
|
-
console.log('
|
|
653
|
+
if (mergedHooks.length > 0) {
|
|
654
|
+
console.log('\nLocal hook changes backed up for: ' + mergedHooks.join(', '));
|
|
655
|
+
console.log('Backed up to ~/.claude/.estack-backup/hooks/');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (DRY_RUN) {
|
|
659
|
+
if (hookInstalled) {
|
|
660
|
+
console.log('\n[dry run] Would add auto-update hook to ~/.claude/settings.json');
|
|
661
|
+
} else {
|
|
662
|
+
console.log('\nAuto-update hook already configured (no change).');
|
|
663
|
+
}
|
|
664
|
+
if (nudgeHookInstalled) {
|
|
665
|
+
console.log('[dry run] Would register repo-search nudge hook in settings.json.');
|
|
666
|
+
} else {
|
|
667
|
+
console.log('Repo-search nudge hook already configured (no change).');
|
|
668
|
+
}
|
|
399
669
|
} else {
|
|
400
|
-
|
|
670
|
+
if (hookInstalled) {
|
|
671
|
+
console.log('\nAuto-update hook added to ~/.claude/settings.json');
|
|
672
|
+
console.log('Skills will update automatically when you start Claude Code.');
|
|
673
|
+
} else {
|
|
674
|
+
console.log('\nAuto-update hook already configured.');
|
|
675
|
+
}
|
|
676
|
+
if (nudgeHookInstalled) {
|
|
677
|
+
console.log('Repo-search nudge hook registered in settings.json.');
|
|
678
|
+
}
|
|
401
679
|
}
|
|
680
|
+
|
|
402
681
|
console.log('');
|
|
403
682
|
}
|
|
404
683
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook: nudges toward the repo-search skill when GitHub is involved.
|
|
3
|
+
// Fires on every WebFetch or WebSearch that touches a github.com URL.
|
|
4
|
+
|
|
5
|
+
const SKILL_NAME = "estack-repo-search";
|
|
6
|
+
|
|
7
|
+
let input = "";
|
|
8
|
+
process.stdin.on("data", (c) => (input += c));
|
|
9
|
+
process.stdin.on("end", () => {
|
|
10
|
+
try { run(input); } catch { /* never break the tool */ }
|
|
11
|
+
process.exit(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function emitNudge() {
|
|
15
|
+
process.stdout.write(JSON.stringify({
|
|
16
|
+
hookSpecificOutput: {
|
|
17
|
+
hookEventName: "PostToolUse",
|
|
18
|
+
additionalContext: `GitHub repo detected. For deeper code questions, consider the ${SKILL_NAME} skill — it clones and greps the repo locally, which is usually faster and more accurate than web fetching multiple GitHub pages.`
|
|
19
|
+
}
|
|
20
|
+
}) + "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function run(raw) {
|
|
24
|
+
let payload;
|
|
25
|
+
try { payload = JSON.parse(raw); } catch { return; }
|
|
26
|
+
|
|
27
|
+
const tool = payload.tool_name;
|
|
28
|
+
if (tool !== "WebFetch" && tool !== "WebSearch") return;
|
|
29
|
+
|
|
30
|
+
if (/github\.com/i.test(raw)) emitNudge();
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elliot-stack",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"description": "Elliot's skill stack for Claude Code — install via npx elliot-stack@latest",
|
|
5
5
|
"bin": {
|
|
6
6
|
"elliot-stack": "bin/install.cjs"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
10
|
-
"skills/"
|
|
10
|
+
"skills/",
|
|
11
|
+
"hooks/"
|
|
11
12
|
],
|
|
12
13
|
"keywords": [
|
|
13
14
|
"claude-code",
|