elliot-stack 1.0.17 → 1.0.18

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 CHANGED
@@ -52,6 +52,12 @@ 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
+ See [`docs/publishing.md`](docs/publishing.md) for the release flow and security model.
60
+
55
61
  ## License
56
62
 
57
63
  MIT
package/bin/install.cjs CHANGED
@@ -15,6 +15,8 @@ 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');
@@ -39,6 +41,13 @@ function walkDir(dir, base) {
39
41
  return files;
40
42
  }
41
43
 
44
+ function computeFileHash(filePath) {
45
+ if (!fs.existsSync(filePath)) return null;
46
+ const hash = crypto.createHash('sha256');
47
+ hash.update(fs.readFileSync(filePath));
48
+ return hash.digest('hex');
49
+ }
50
+
42
51
  function computeSkillHash(skillDir) {
43
52
  if (!fs.existsSync(skillDir)) return null;
44
53
  const hash = crypto.createHash('sha256');
@@ -66,6 +75,14 @@ function backupSkill(name) {
66
75
  copyDir(installedDir, path.join(BACKUP_DIR, name));
67
76
  }
68
77
 
78
+ function backupHook(filename) {
79
+ const installedFile = path.join(HOOKS_DIR, filename);
80
+ if (!fs.existsSync(installedFile)) return;
81
+ const dest = path.join(BACKUP_DIR, 'hooks', filename);
82
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
83
+ fs.copyFileSync(installedFile, dest);
84
+ }
85
+
69
86
  function promptChar(question) {
70
87
  if (!process.stdin.isTTY) {
71
88
  // Non-interactive environment — read a line from piped stdin
@@ -118,7 +135,7 @@ function getSkillDescription(skillDir) {
118
135
  return '';
119
136
  }
120
137
 
121
- // ── Startup hook setup ─────────────────────────────────────────────────────
138
+ // ── Hook setup ─────────────────────────────────────────────────────────────
122
139
 
123
140
  function setupStartupHook() {
124
141
  let settings = {};
@@ -164,6 +181,45 @@ function setupStartupHook() {
164
181
  return true;
165
182
  }
166
183
 
184
+ function setupRepoSearchNudgeHook() {
185
+ let settings = {};
186
+ if (fs.existsSync(SETTINGS_FILE)) {
187
+ try {
188
+ settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
189
+ } catch (_) {
190
+ settings = {};
191
+ }
192
+ }
193
+
194
+ if (settings.hooks && settings.hooks.PostToolUse) {
195
+ for (const group of settings.hooks.PostToolUse) {
196
+ if (group.matcher === 'WebFetch|WebSearch' && group.hooks) {
197
+ for (const hook of group.hooks) {
198
+ if (hook.command && hook.command.includes('repo-search-nudge.js')) {
199
+ return false;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ if (!settings.hooks) settings.hooks = {};
207
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
208
+
209
+ settings.hooks.PostToolUse.push({
210
+ matcher: 'WebFetch|WebSearch',
211
+ hooks: [{
212
+ type: 'command',
213
+ command: `node "${path.join(HOOKS_DIR, 'repo-search-nudge.js').replace(/\\/g, '/')}"`,
214
+ timeout: 5,
215
+ }],
216
+ });
217
+
218
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
219
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
220
+ return true;
221
+ }
222
+
167
223
  // ── Main ────────────────────────────────────────────────────────────────────
168
224
 
169
225
  async function main() {
@@ -191,6 +247,16 @@ async function main() {
191
247
  packageHashes[name] = computeSkillHash(path.join(PACKAGE_SKILLS_DIR, name));
192
248
  }
193
249
 
250
+ // 2b. Scan package hooks
251
+ const hookFilenames = fs.existsSync(PACKAGE_HOOKS_DIR)
252
+ ? fs.readdirSync(PACKAGE_HOOKS_DIR).filter((f) => f.endsWith('.js')).sort()
253
+ : [];
254
+
255
+ const packageHookHashes = {};
256
+ for (const filename of hookFilenames) {
257
+ packageHookHashes[filename] = computeFileHash(path.join(PACKAGE_HOOKS_DIR, filename));
258
+ }
259
+
194
260
  // 3. Load existing checksums
195
261
  let storedChecksums = {};
196
262
  if (fs.existsSync(CHECKSUMS_FILE)) {
@@ -230,9 +296,36 @@ async function main() {
230
296
  }
231
297
  }
232
298
 
299
+ // 4b. Detect local modifications and needed updates for hooks
300
+ const modifiedHooks = [];
301
+ const hooksNeedingUpdate = [];
302
+ for (const filename of hookFilenames) {
303
+ const installedFile = path.join(HOOKS_DIR, filename);
304
+ const key = 'hook:' + filename;
305
+ if (!fs.existsSync(installedFile)) {
306
+ hooksNeedingUpdate.push(filename);
307
+ continue;
308
+ }
309
+ const currentHash = computeFileHash(installedFile);
310
+ if (!storedChecksums[key]) {
311
+ if (currentHash !== packageHookHashes[filename]) {
312
+ modifiedHooks.push(filename);
313
+ hooksNeedingUpdate.push(filename);
314
+ }
315
+ } else if (currentHash !== storedChecksums[key]) {
316
+ modifiedHooks.push(filename);
317
+ if (storedChecksums[key] !== packageHookHashes[filename]) {
318
+ hooksNeedingUpdate.push(filename);
319
+ }
320
+ } else if (currentHash !== packageHookHashes[filename]) {
321
+ hooksNeedingUpdate.push(filename);
322
+ }
323
+ }
324
+
233
325
  // 5. Silent mode — no output at all
234
326
  if (SILENT) {
235
- if (needsUpdate.length === 0 && modifiedSkills.length === 0) {
327
+ if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
328
+ hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
236
329
  process.exit(0);
237
330
  }
238
331
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -243,13 +336,21 @@ async function main() {
243
336
  copyDir(path.join(PACKAGE_SKILLS_DIR, name), path.join(SKILLS_DIR, name));
244
337
  newChecksums[name] = packageHashes[name];
245
338
  }
339
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
340
+ for (const filename of hookFilenames) {
341
+ if (modifiedHooks.includes(filename)) continue;
342
+ if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
343
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
344
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
345
+ }
246
346
  fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
247
347
  process.exit(0);
248
348
  }
249
349
 
250
350
  // 6. Startup mode — non-interactive, backup + merge context for Claude Code
251
351
  if (STARTUP) {
252
- if (needsUpdate.length === 0 && modifiedSkills.length === 0) {
352
+ if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
353
+ hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
253
354
  process.exit(0);
254
355
  }
255
356
 
@@ -273,6 +374,27 @@ async function main() {
273
374
  updated.push(name);
274
375
  }
275
376
 
377
+ // Install hooks
378
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
379
+ const updatedHooks = [];
380
+ const mergeNeededHooks = [];
381
+
382
+ for (const filename of hookFilenames) {
383
+ if (modifiedHooks.includes(filename)) {
384
+ backupHook(filename);
385
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
386
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
387
+ mergeNeededHooks.push(filename);
388
+ continue;
389
+ }
390
+ if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
391
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
392
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
393
+ updatedHooks.push(filename);
394
+ }
395
+
396
+ setupRepoSearchNudgeHook();
397
+
276
398
  fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
277
399
 
278
400
  // Build output for Claude Code
@@ -283,6 +405,10 @@ async function main() {
283
405
  msgParts.push('estack: updated ' + updated.join(', '));
284
406
  }
285
407
 
408
+ if (updatedHooks.length > 0) {
409
+ msgParts.push('estack: updated hooks ' + updatedHooks.join(', '));
410
+ }
411
+
286
412
  if (mergeNeeded.length > 0) {
287
413
  const backupPath = BACKUP_DIR.replace(HOME, '~');
288
414
  msgParts.push(
@@ -299,6 +425,21 @@ async function main() {
299
425
  'identify the user\'s changes, and apply them to the new version where compatible.';
300
426
  }
301
427
 
428
+ if (mergeNeededHooks.length > 0) {
429
+ const backupPath = BACKUP_DIR.replace(HOME, '~');
430
+ msgParts.push(
431
+ 'estack: updated hooks ' + mergeNeededHooks.join(', ') +
432
+ ' (local changes backed up to ' + backupPath + '/hooks/)'
433
+ );
434
+ const existingContext = output.additionalContext ? output.additionalContext + ' ' : '';
435
+ output.additionalContext =
436
+ existingContext +
437
+ 'estack hooks were updated but the user had local modifications to: ' +
438
+ mergeNeededHooks.join(', ') + '. ' +
439
+ 'Their previous versions are saved at ' + path.join(BACKUP_DIR, 'hooks') + '. ' +
440
+ 'The new upstream versions are now installed at ' + HOOKS_DIR + '.';
441
+ }
442
+
302
443
  if (msgParts.length > 0) {
303
444
  output.systemMessage = msgParts.join('\n');
304
445
  }
@@ -312,10 +453,19 @@ async function main() {
312
453
  // 7. Interactive mode — prompt if modifications detected
313
454
  let modifiedAction = null; // 'overwrite', 'skip', or 'merge'
314
455
 
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);
456
+ if (modifiedSkills.length > 0 || modifiedHooks.length > 0) {
457
+ console.log('\nThe following items have been modified locally:');
458
+ if (modifiedSkills.length > 0) {
459
+ console.log(' Skills:');
460
+ for (const name of modifiedSkills) {
461
+ console.log(' - ' + name);
462
+ }
463
+ }
464
+ if (modifiedHooks.length > 0) {
465
+ console.log(' Hooks:');
466
+ for (const filename of modifiedHooks) {
467
+ console.log(' - ' + filename);
468
+ }
319
469
  }
320
470
  console.log('\nChoose an action:');
321
471
  console.log(' [o] Overwrite all (replace with latest)');
@@ -371,15 +521,49 @@ async function main() {
371
521
  console.log(' Installed ' + name);
372
522
  }
373
523
 
524
+ // 8b. Install hooks
525
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
526
+ let installedHookCount = 0;
527
+ const mergedHooks = [];
528
+
529
+ for (const filename of hookFilenames) {
530
+ if (modifiedHooks.includes(filename)) {
531
+ if (modifiedAction === 'skip') {
532
+ console.log(' Skipped hook ' + filename + ' (local modifications preserved)');
533
+ const currentHash = computeFileHash(path.join(HOOKS_DIR, filename));
534
+ if (currentHash) newChecksums['hook:' + filename] = currentHash;
535
+ continue;
536
+ }
537
+ if (modifiedAction === 'merge') {
538
+ backupHook(filename);
539
+ mergedHooks.push(filename);
540
+ console.log(' Backed up hook ' + filename + ' → ~/.claude/.estack-backup/hooks/' + filename);
541
+ }
542
+ // overwrite or merge — fall through to install
543
+ } else if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) {
544
+ // Already installed and up-to-date
545
+ continue;
546
+ }
547
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
548
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
549
+ installedHookCount++;
550
+ console.log(' Installed hook ' + filename);
551
+ }
552
+
374
553
  // 9. Write checksums
375
554
  fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
376
555
 
377
- // 10. Setup startup hook
556
+ // 10. Setup startup hook and repo-search nudge hook
378
557
  const hookInstalled = setupStartupHook();
558
+ const nudgeHookInstalled = setupRepoSearchNudgeHook();
379
559
 
380
560
  // 11. Summary output
381
561
  console.log('\nestack installed successfully!\n');
382
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.claude/skills/\n');
562
+ console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.claude/skills/');
563
+ if (installedHookCount > 0) {
564
+ console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' installed to ~/.claude/hooks/');
565
+ }
566
+ console.log('');
383
567
  console.log('Skills available:');
384
568
 
385
569
  for (const name of skillNames) {
@@ -393,12 +577,22 @@ async function main() {
393
577
  console.log(' "Merge my estack changes from ~/.claude/.estack-backup/"');
394
578
  }
395
579
 
580
+ if (mergedHooks.length > 0) {
581
+ console.log('\nLocal hook changes backed up for: ' + mergedHooks.join(', '));
582
+ console.log('Backed up to ~/.claude/.estack-backup/hooks/');
583
+ }
584
+
396
585
  if (hookInstalled) {
397
586
  console.log('\nAuto-update hook added to ~/.claude/settings.json');
398
587
  console.log('Skills will update automatically when you start Claude Code.');
399
588
  } else {
400
589
  console.log('\nAuto-update hook already configured.');
401
590
  }
591
+
592
+ if (nudgeHookInstalled) {
593
+ console.log('Repo-search nudge hook registered in settings.json.');
594
+ }
595
+
402
596
  console.log('');
403
597
  }
404
598
 
@@ -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.18",
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",
@@ -90,68 +90,3 @@ print(base + '?title=' + urllib.parse.quote(title) + '&body=' + urllib.parse.quo
90
90
  ```
91
91
 
92
92
  Share the printed URL with the user. They click it, review the pre-filled title and body, then click **Submit new issue**.
93
-
94
- ---
95
-
96
- ## Skill Feedback
97
- ---
98
-
99
- ## Skill Feedback
100
-
101
- If the user shares feedback about this skill — a bug, something confusing, a missing feature, or a suggestion — ask them to describe it in a bit more detail (what they expected, what happened, and any relevant context). Then file the issue using whichever method is available:
102
-
103
- **If `gh` is installed** (`gh --version` succeeds), create the issue directly:
104
-
105
- ```bash
106
- gh issue create \
107
- --repo ElliotDrel/e-stack \
108
- --title "estack-repo-search: <concise summary>" \
109
- --body "<description from user feedback — expected vs. actual behavior and context>"
110
- ```
111
-
112
- **If `gh` is not installed**, build a pre-filled URL:
113
-
114
- ```bash
115
- python3 -c "
116
- import urllib.parse
117
- title = 'estack-repo-search: <concise summary>'
118
- body = '<description from user feedback — expected vs. actual behavior and context>'
119
- base = 'https://github.com/ElliotDrel/e-stack/issues/new'
120
- print(base + '?title=' + urllib.parse.quote(title) + '&body=' + urllib.parse.quote(body))
121
- "
122
- ```
123
-
124
- Share the printed URL with the user and offer to open it in their browser.
125
-
126
- They can also click it directly, review the pre-filled title and body, and click **Submit new issue**.
127
-
128
- ---
129
-
130
- ## Skill Feedback
131
-
132
- If the user shares feedback about this skill — a bug, something confusing, a missing feature, or a suggestion — ask them to describe it in a bit more detail (what they expected, what happened, and any relevant context). Then file the issue using whichever method is available:
133
-
134
- **If `gh` is installed** (`gh --version` succeeds), create the issue directly:
135
-
136
- ```bash
137
- gh issue create \
138
- --repo ElliotDrel/e-stack \
139
- --title "estack-repo-search: <concise summary>" \
140
- --body "<description from user feedback — expected vs. actual behavior and context>"
141
- ```
142
-
143
- **If `gh` is not installed**, build a pre-filled URL:
144
-
145
- ```bash
146
- python3 -c "
147
- import urllib.parse
148
- title = 'estack-repo-search: <concise summary>'
149
- body = '<description from user feedback — expected vs. actual behavior and context>'
150
- base = 'https://github.com/ElliotDrel/e-stack/issues/new'
151
- print(base + '?title=' + urllib.parse.quote(title) + '&body=' + urllib.parse.quote(body))
152
- "
153
- ```
154
-
155
- Share the printed URL with the user and offer to open it in their browser.
156
-
157
- They can also click it directly, review the pre-filled title and body, and click **Submit new issue**.