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.
Files changed (46) hide show
  1. package/README.md +17 -0
  2. package/bin/install.cjs +322 -43
  3. package/hooks/repo-search-nudge.js +31 -0
  4. package/package.json +3 -2
  5. package/skills/estack-read-claude-session-history/SKILL.md +196 -0
  6. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -0
  7. package/skills/estack-read-claude-session-history/references/modes.md +366 -0
  8. package/skills/estack-read-claude-session-history/references/recipes.md +237 -0
  9. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -0
  10. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -0
  11. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -0
  12. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -0
  13. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -0
  14. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -0
  15. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1448 -0
  16. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -0
  17. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -0
  18. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -0
  19. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -0
  20. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -0
  21. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -0
  22. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -0
  23. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -0
  24. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -0
  25. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -0
  26. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -0
  27. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -0
  28. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -0
  29. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -0
  30. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -0
  31. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +3 -0
  32. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -0
  33. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -0
  34. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -0
  35. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -0
  36. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -0
  37. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -0
  38. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -0
  39. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -0
  40. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -0
  41. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -0
  42. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -0
  43. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +175 -0
  44. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -0
  45. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -0
  46. 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 contents = fs.readFileSync(fullPath);
71
+ const raw = fs.readFileSync(fullPath);
49
72
  hash.update(relPath.replace(/\\/g, '/'));
50
- hash.update(contents);
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
- // ── Startup hook setup ─────────────────────────────────────────────────────
152
+ // ── Hook setup ─────────────────────────────────────────────────────────────
122
153
 
123
- function setupStartupHook() {
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 (storedChecksums[name] !== packageHashes[name]) {
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 skills have been modified locally:');
317
- for (const name of modifiedSkills) {
318
- console.log(' - ' + name);
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 (answer === 'a') {
330
- console.log('Installation aborted.');
331
- process.exit(0);
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('Invalid choice. Installation aborted.');
340
- process.exit(1);
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
- copyDir(path.join(PACKAGE_SKILLS_DIR, name), path.join(SKILLS_DIR, name));
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
- console.log(' Installed ' + name);
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
- const hookInstalled = setupStartupHook();
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
- console.log('\nestack installed successfully!\n');
382
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.claude/skills/\n');
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 (hookInstalled) {
397
- console.log('\nAuto-update hook added to ~/.claude/settings.json');
398
- console.log('Skills will update automatically when you start Claude Code.');
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
- console.log('\nAuto-update hook already configured.');
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.17",
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",