elliot-stack 1.0.29 → 1.0.33

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 (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -0
  3. package/bin/install.cjs +981 -950
  4. package/hooks/repo-search-nudge.js +32 -32
  5. package/package.json +1 -1
  6. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  7. package/skills/estack-better-title/SKILL.md +64 -64
  8. package/skills/estack-better-title/scripts/rename.sh +55 -55
  9. package/skills/estack-chris-voss/SKILL.md +80 -80
  10. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  11. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  12. package/skills/estack-customer-discovery/SKILL.md +60 -60
  13. package/skills/estack-flight-planner/SKILL.md +332 -332
  14. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  15. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  16. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  17. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  18. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  19. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  20. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  21. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  22. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  23. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  24. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  25. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  26. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  27. package/skills/estack-leadership-coach/SKILL.md +235 -0
  28. package/skills/estack-leadership-coach/adding-references.md +280 -0
  29. package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
  30. package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
  31. package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
  32. package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
  33. package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
  34. package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
  35. package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
  36. package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
  37. package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
  38. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
  39. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
  40. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
  41. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
  42. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
  43. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
  44. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
  45. package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
  46. package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
  47. package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
  48. package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
  49. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
  50. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
  51. package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
  52. package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
  53. package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
  54. package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
  55. package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
  56. package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
  57. package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
  58. package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
  59. package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
  60. package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
  61. package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
  62. package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
  63. package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
  64. package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
  65. package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
  66. package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
  67. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
  68. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
  69. package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
  70. package/skills/estack-pdf-to-md/SKILL.md +180 -0
  71. package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
  72. package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
  73. package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
  74. package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
  75. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  76. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  77. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  78. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  79. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  80. package/skills/estack-read-claude-session-history/SKILL.md +204 -204
  81. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  82. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  83. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  84. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  85. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  86. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  87. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  88. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  89. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  90. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  91. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  92. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  93. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  94. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  95. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  96. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  97. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  98. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  99. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  100. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  101. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  102. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  103. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  104. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  105. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  106. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  107. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  108. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  109. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  110. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  111. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  112. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  113. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  114. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  115. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  116. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  117. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  118. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  119. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  120. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  121. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  122. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  123. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  124. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  125. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  126. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
  127. package/skills/estack-repo-search/SKILL.md +65 -65
  128. package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
package/bin/install.cjs CHANGED
@@ -1,950 +1,981 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const fs = require('fs');
5
- const path = require('path');
6
- const crypto = require('crypto');
7
- const os = require('os');
8
- const readline = require('readline');
9
-
10
- // ── Paths ──────────────────────────────────────────────────────────────────
11
- const HOME = os.homedir();
12
- const CLAUDE_DIR = path.join(HOME, '.claude');
13
- const SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
14
- const AGENTS_ROOT = path.join(HOME, '.agents');
15
- const AGENTS_DIR = path.join(AGENTS_ROOT, 'skills');
16
- const BACKUP_DIR = path.join(HOME, '.estack-backup');
17
- const CHECKSUMS_FILE = path.join(CLAUDE_DIR, '.estack-checksums.json');
18
- const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
19
- const PACKAGE_SKILLS_DIR = path.join(__dirname, '..', 'skills');
20
- const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
21
- const PACKAGE_HOOKS_DIR = path.join(__dirname, '..', 'hooks');
22
-
23
- // ── Migrate backup dir from old location (inside .claude) to user root ──────
24
- (function migrateBackupDir() {
25
- const OLD_BACKUP_DIR = path.join(CLAUDE_DIR, '.estack-backup');
26
- if (!fs.existsSync(OLD_BACKUP_DIR)) return;
27
- if (fs.existsSync(BACKUP_DIR)) return; // new location already exists, leave both alone
28
- const silent = process.argv.includes('--silent');
29
- const isDryRun = process.argv.includes('--dry-run') ||
30
- (!__dirname.includes('node_modules') && !process.argv.includes('--install'));
31
- if (isDryRun) {
32
- if (!silent) {
33
- process.stderr.write(
34
- 'estack: [dry run] Would move backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
35
- );
36
- }
37
- return;
38
- }
39
- try {
40
- fs.renameSync(OLD_BACKUP_DIR, BACKUP_DIR);
41
- if (!silent) {
42
- process.stderr.write(
43
- 'estack: moved backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
44
- );
45
- }
46
- } catch (e) {
47
- // rename across drives/filesystems — fall back to copy+delete
48
- try {
49
- copyDirRaw(OLD_BACKUP_DIR, BACKUP_DIR);
50
- removeDirRaw(OLD_BACKUP_DIR);
51
- if (!silent) {
52
- process.stderr.write(
53
- 'estack: migrated backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
54
- );
55
- }
56
- } catch (e2) {
57
- process.stderr.write(
58
- 'estack: WARNING — could not migrate backup dir from ' + OLD_BACKUP_DIR +
59
- ' to ' + BACKUP_DIR + ': ' + e2.message + '\n'
60
- );
61
- }
62
- }
63
- })();
64
-
65
- // ── Migrate skills from ~/.agents/<name> (v1.0.23 layout) to ~/.agents/skills/ ──
66
- (function migrateAgentsLayout() {
67
- let strays;
68
- try {
69
- // statSync guards against ~/.agents existing as a plain file
70
- if (!fs.existsSync(AGENTS_ROOT) || !fs.statSync(AGENTS_ROOT).isDirectory()) return;
71
- strays = fs.readdirSync(AGENTS_ROOT, { withFileTypes: true })
72
- .filter((e) => e.isDirectory() && e.name.startsWith('estack-'));
73
- } catch (_) {
74
- return; // unreadable — let main() surface a real error if it matters
75
- }
76
- if (strays.length === 0) return;
77
- const silent = process.argv.includes('--silent');
78
- const isDryRun = process.argv.includes('--dry-run') ||
79
- (!__dirname.includes('node_modules') && !process.argv.includes('--install'));
80
- if (isDryRun) {
81
- if (!silent) {
82
- process.stderr.write(
83
- 'estack: [dry run] Would move ' + strays.length + ' skill(s) from ~/.agents/ to ~/.agents/skills/\n'
84
- );
85
- }
86
- return;
87
- }
88
- try {
89
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
90
- } catch (err) {
91
- process.stderr.write(
92
- 'estack: WARNING — could not create ~/.agents/skills/: ' + err.message + '\n'
93
- );
94
- return;
95
- }
96
- for (const e of strays) {
97
- const oldPath = path.join(AGENTS_ROOT, e.name);
98
- const newPath = path.join(AGENTS_DIR, e.name);
99
- try {
100
- if (fs.existsSync(newPath)) {
101
- // already migrated — drop the stale copy at the old location
102
- fs.rmSync(oldPath, { recursive: true, force: true });
103
- } else {
104
- try {
105
- fs.renameSync(oldPath, newPath);
106
- } catch (_) {
107
- copyDirRaw(oldPath, newPath);
108
- removeDirRaw(oldPath);
109
- }
110
- }
111
- // re-point the live symlink — the old junction now dangles
112
- ensureSymlink(newPath, path.join(SKILLS_DIR, e.name));
113
- } catch (err) {
114
- process.stderr.write(
115
- 'estack: WARNING — could not migrate ' + e.name + ' to ~/.agents/skills/: ' + err.message + '\n'
116
- );
117
- }
118
- }
119
- if (!silent) {
120
- process.stderr.write(
121
- 'estack: moved ' + strays.length + ' skill(s) from ~/.agents/ to ~/.agents/skills/\n'
122
- );
123
- }
124
- })();
125
-
126
- function copyDirRaw(src, dest) {
127
- fs.mkdirSync(dest, { recursive: true });
128
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
129
- const s = path.join(src, entry.name);
130
- const d = path.join(dest, entry.name);
131
- if (entry.isDirectory()) copyDirRaw(s, d);
132
- else fs.copyFileSync(s, d);
133
- }
134
- }
135
-
136
- function removeDirRaw(dir) {
137
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
138
- const full = path.join(dir, entry.name);
139
- if (entry.isDirectory()) removeDirRaw(full);
140
- else fs.unlinkSync(full);
141
- }
142
- fs.rmdirSync(dir);
143
- }
144
-
145
- function isSymlink(p) {
146
- try { return fs.lstatSync(p).isSymbolicLink(); } catch (_) { return false; }
147
- }
148
-
149
- // True only for a real directory at p — not a symlink to one, not a file.
150
- function isRealDir(p) {
151
- try {
152
- const stat = fs.lstatSync(p);
153
- return stat.isDirectory() && !stat.isSymbolicLink();
154
- } catch (_) {
155
- return false;
156
- }
157
- }
158
-
159
- // Creates (or updates) a directory symlink at linkPath pointing to target.
160
- // On Windows uses 'junction' (no elevation required); on Unix uses 'dir'.
161
- function ensureSymlink(target, linkPath) {
162
- try {
163
- const stat = fs.lstatSync(linkPath);
164
- if (stat.isSymbolicLink()) {
165
- if (path.resolve(fs.readlinkSync(linkPath)) === path.resolve(target)) return;
166
- fs.unlinkSync(linkPath);
167
- } else {
168
- // real dir, plain file, or anything else occupying the link path
169
- fs.rmSync(linkPath, { recursive: true, force: true });
170
- }
171
- } catch (_) {}
172
- fs.mkdirSync(path.dirname(linkPath), { recursive: true });
173
- const type = process.platform === 'win32' ? 'junction' : 'dir';
174
- fs.symlinkSync(target, linkPath, type);
175
- }
176
-
177
- // ── Flags ──────────────────────────────────────────────────────────────────
178
- const SILENT = process.argv.includes('--silent');
179
- const STARTUP = process.argv.includes('--startup');
180
- // When run directly from the repo (not via npx/node_modules), default to dry-run
181
- // so local testing never silently clobbers the live ~/.claude/skills install.
182
- // Pass --install to actually write files, or --dry-run to force preview mode.
183
- const IS_LOCAL = !__dirname.includes('node_modules');
184
- const DRY_RUN = process.argv.includes('--dry-run') ||
185
- (IS_LOCAL && !process.argv.includes('--install'));
186
-
187
- // ── Deprecated skills ──────────────────────────────────────────────────────
188
- // Skills that were renamed or removed. The installer removes these on every
189
- // run so users don't end up with both the old and new name installed.
190
- const DEPRECATED_SKILLS = [
191
- 'estack-prompt-builder', // renamed to estack-prompt-builder-coach
192
- ];
193
-
194
- // ── Helpers ────────────────────────────────────────────────────────────────
195
-
196
- const HASH_IGNORE_DIRS = new Set(['__pycache__', '.git', 'node_modules']);
197
- const HASH_IGNORE_EXTS = new Set(['.pyc', '.pyo']);
198
-
199
- function walkDir(dir, base) {
200
- base = base || dir;
201
- const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
202
- a.name < b.name ? -1 : a.name > b.name ? 1 : 0
203
- );
204
- const files = [];
205
- for (const entry of entries) {
206
- const full = path.join(dir, entry.name);
207
- if (entry.isDirectory()) {
208
- if (!HASH_IGNORE_DIRS.has(entry.name)) files.push(...walkDir(full, base));
209
- } else if (!HASH_IGNORE_EXTS.has(path.extname(entry.name))) {
210
- files.push(path.relative(base, full));
211
- }
212
- }
213
- return files;
214
- }
215
-
216
- function computeFileHash(filePath) {
217
- if (!fs.existsSync(filePath)) return null;
218
- const hash = crypto.createHash('sha256');
219
- const raw = fs.readFileSync(filePath);
220
- hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
221
- return hash.digest('hex');
222
- }
223
-
224
- function computeSkillHash(skillDir) {
225
- // statSync (not lstat) so symlinked dirs hash their contents; plain files → null
226
- try {
227
- if (!fs.statSync(skillDir).isDirectory()) return null;
228
- } catch (_) {
229
- return null;
230
- }
231
- const hash = crypto.createHash('sha256');
232
- const files = walkDir(skillDir, skillDir);
233
- for (const relPath of files) {
234
- const fullPath = path.join(skillDir, relPath);
235
- const raw = fs.readFileSync(fullPath);
236
- hash.update(relPath.replace(/\\/g, '/'));
237
- hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
238
- }
239
- return hash.digest('hex');
240
- }
241
-
242
- function copyDir(src, dest) {
243
- if (fs.existsSync(dest)) {
244
- fs.rmSync(dest, { recursive: true, force: true });
245
- }
246
- fs.cpSync(src, dest, { recursive: true });
247
- }
248
-
249
- function backupSkill(name) {
250
- const agentsDir = path.join(AGENTS_DIR, name);
251
- const installedDir = fs.existsSync(agentsDir) ? agentsDir : path.join(SKILLS_DIR, name);
252
- if (!fs.existsSync(installedDir)) return;
253
- fs.mkdirSync(BACKUP_DIR, { recursive: true });
254
- copyDir(installedDir, path.join(BACKUP_DIR, name));
255
- }
256
-
257
- function backupHook(filename) {
258
- const installedFile = path.join(HOOKS_DIR, filename);
259
- if (!fs.existsSync(installedFile)) return;
260
- const dest = path.join(BACKUP_DIR, 'hooks', filename);
261
- fs.mkdirSync(path.dirname(dest), { recursive: true });
262
- fs.copyFileSync(installedFile, dest);
263
- }
264
-
265
- function promptChar(question) {
266
- if (!process.stdin.isTTY) {
267
- // Non-interactive environment — read a line from piped stdin
268
- return new Promise((resolve) => {
269
- let resolved = false;
270
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
271
- rl.question(question, (answer) => {
272
- if (!resolved) {
273
- resolved = true;
274
- rl.close();
275
- resolve((answer || '').toLowerCase().trim()[0] || '');
276
- }
277
- });
278
- // If stdin is already closed, default to abort
279
- rl.once('close', () => {
280
- if (!resolved) {
281
- resolved = true;
282
- resolve('a');
283
- }
284
- });
285
- });
286
- }
287
- return new Promise((resolve) => {
288
- process.stdout.write(question);
289
- process.stdin.setRawMode(true);
290
- process.stdin.resume();
291
- process.stdin.once('data', (chunk) => {
292
- const char = chunk.toString().toLowerCase().trim()[0] || '';
293
- try { process.stdin.setRawMode(false); } catch (_) {}
294
- process.stdin.pause();
295
- process.stdout.write('\n');
296
- resolve(char);
297
- });
298
- });
299
- }
300
-
301
- function getSkillDescription(skillDir) {
302
- const skillMd = path.join(skillDir, 'SKILL.md');
303
- if (!fs.existsSync(skillMd)) return '';
304
- const content = fs.readFileSync(skillMd, 'utf8');
305
- const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
306
- if (!frontmatterMatch) return '';
307
- const fm = frontmatterMatch[1];
308
- const singleLine = fm.match(/^description:\s*(\S.*)$/m);
309
- if (singleLine && !/^[>|]/.test(singleLine[1])) return singleLine[1].trim();
310
- const multiLine = fm.match(/^description:\s*[>|][->+]?\r?\n((?:[ \t]+.*\r?\n?)+)/m);
311
- if (multiLine) {
312
- return multiLine[1].replace(/\s+/g, ' ').trim();
313
- }
314
- return '';
315
- }
316
-
317
- // Per-skill version from SKILL.md frontmatter (`version: x.y.z`).
318
- // Versions are the human-readable label; content hashes remain the
319
- // update-detection source of truth (scripts/check-versions.cjs keeps them in sync).
320
- function getSkillVersion(skillDir) {
321
- const skillMd = path.join(skillDir, 'SKILL.md');
322
- if (!fs.existsSync(skillMd)) return null;
323
- const content = fs.readFileSync(skillMd, 'utf8');
324
- const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
325
- if (!frontmatterMatch) return null;
326
- const m = frontmatterMatch[1].match(/^version:\s*(\S+)\s*$/m);
327
- return m ? m[1] : null;
328
- }
329
-
330
- // Version of the currently installed copy of a skill (agents dir, falling
331
- // back to the legacy skills dir for pre-migration installs).
332
- function getInstalledSkillVersion(name) {
333
- const agentsDir = path.join(AGENTS_DIR, name);
334
- if (fs.existsSync(agentsDir)) return getSkillVersion(agentsDir);
335
- return getSkillVersion(path.join(SKILLS_DIR, name));
336
- }
337
-
338
- // Per-hook version from a `// @version x.y.z` comment near the top.
339
- function getHookVersion(filePath) {
340
- if (!fs.existsSync(filePath)) return null;
341
- const m = fs.readFileSync(filePath, 'utf8').match(/^\/\/ @version\s+(\S+)\s*$/m);
342
- return m ? m[1] : null;
343
- }
344
-
345
- // "name (1.0.0 → 1.1.0)" for updates, "name (v1.1.0)" for fresh installs.
346
- function withVersion(name, oldV, newV) {
347
- if (oldV && newV && oldV !== newV) return name + ' (' + oldV + ' → ' + newV + ')';
348
- if (newV) return name + ' (v' + newV + ')';
349
- return name;
350
- }
351
-
352
- // Copies a skill to ~/.agents/skills/<name> and creates/updates the symlink at ~/.claude/skills/<name>.
353
- // If a real (non-symlink) directory already exists at the skills path, it is removed first.
354
- function installSkillFiles(name) {
355
- const agentsSkillDir = path.join(AGENTS_DIR, name);
356
- const skillsLinkDir = path.join(SKILLS_DIR, name);
357
- if (!isSymlink(skillsLinkDir) && fs.existsSync(skillsLinkDir)) {
358
- fs.rmSync(skillsLinkDir, { recursive: true, force: true });
359
- }
360
- copyDir(path.join(PACKAGE_SKILLS_DIR, name), agentsSkillDir);
361
- ensureSymlink(agentsSkillDir, skillsLinkDir);
362
- }
363
-
364
- // ── Hook setup ─────────────────────────────────────────────────────────────
365
-
366
- // Returns true if the hook was added (or would be added in dryRun), false if
367
- // it was already configured. In dryRun mode nothing is written to disk.
368
- function setupStartupHook(dryRun) {
369
- let settings = {};
370
- if (fs.existsSync(SETTINGS_FILE)) {
371
- try {
372
- settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
373
- } catch (_) {
374
- settings = {};
375
- }
376
- }
377
-
378
- if (settings.hooks && settings.hooks.SessionStart) {
379
- const existing = settings.hooks.SessionStart;
380
- for (const group of existing) {
381
- if (group.matcher === 'startup' && group.hooks) {
382
- for (const hook of group.hooks) {
383
- if (hook.command && hook.command.includes('elliot-stack@latest --startup')) {
384
- return false;
385
- }
386
- }
387
- }
388
- }
389
- }
390
-
391
- if (dryRun) return true;
392
-
393
- if (!settings.hooks) settings.hooks = {};
394
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
395
-
396
- let startupGroup = settings.hooks.SessionStart.find(
397
- (g) => g.matcher === 'startup'
398
- );
399
- if (!startupGroup) {
400
- startupGroup = { matcher: 'startup', hooks: [] };
401
- settings.hooks.SessionStart.push(startupGroup);
402
- }
403
-
404
- startupGroup.hooks.push({
405
- type: 'command',
406
- command: 'npx --yes elliot-stack@latest --startup',
407
- });
408
-
409
- fs.mkdirSync(CLAUDE_DIR, { recursive: true });
410
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
411
- return true;
412
- }
413
-
414
- // Returns true if the hook was added (or would be added in dryRun), false if
415
- // it was already configured. In dryRun mode nothing is written to disk.
416
- function setupRepoSearchNudgeHook(dryRun) {
417
- let settings = {};
418
- if (fs.existsSync(SETTINGS_FILE)) {
419
- try {
420
- settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
421
- } catch (_) {
422
- settings = {};
423
- }
424
- }
425
-
426
- if (settings.hooks && settings.hooks.PostToolUse) {
427
- for (const group of settings.hooks.PostToolUse) {
428
- if (group.matcher === 'WebFetch|WebSearch' && group.hooks) {
429
- for (const hook of group.hooks) {
430
- if (hook.command && hook.command.includes('repo-search-nudge.js')) {
431
- return false;
432
- }
433
- }
434
- }
435
- }
436
- }
437
-
438
- if (dryRun) return true;
439
-
440
- if (!settings.hooks) settings.hooks = {};
441
- if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
442
-
443
- settings.hooks.PostToolUse.push({
444
- matcher: 'WebFetch|WebSearch',
445
- hooks: [{
446
- type: 'command',
447
- command: `node "${path.join(HOOKS_DIR, 'repo-search-nudge.js').replace(/\\/g, '/')}"`,
448
- timeout: 5,
449
- }],
450
- });
451
-
452
- fs.mkdirSync(CLAUDE_DIR, { recursive: true });
453
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
454
- return true;
455
- }
456
-
457
- // ── Main ────────────────────────────────────────────────────────────────────
458
-
459
- async function main() {
460
- // 0. Remove deprecated skills (renamed/deleted from the package)
461
- if (fs.existsSync(SKILLS_DIR) || fs.existsSync(AGENTS_DIR)) {
462
- const newChecksums0 = fs.existsSync(CHECKSUMS_FILE)
463
- ? (() => { try { return JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8')); } catch (_) { return {}; } })()
464
- : {};
465
- let changed = false;
466
- for (const name of DEPRECATED_SKILLS) {
467
- const agentsDir = path.join(AGENTS_DIR, name);
468
- const skillsDir = path.join(SKILLS_DIR, name);
469
- let found = false;
470
- if (fs.existsSync(agentsDir)) {
471
- if (!DRY_RUN) fs.rmSync(agentsDir, { recursive: true, force: true });
472
- found = true;
473
- }
474
- if (fs.existsSync(skillsDir) || isSymlink(skillsDir)) {
475
- if (!DRY_RUN) {
476
- try { fs.unlinkSync(skillsDir); } catch (_) { fs.rmSync(skillsDir, { recursive: true, force: true }); }
477
- }
478
- found = true;
479
- }
480
- if (found) {
481
- delete newChecksums0[name];
482
- changed = true;
483
- if (!SILENT && !STARTUP) {
484
- console.log((DRY_RUN ? ' [dry run] Would remove deprecated skill: ' : ' Removed deprecated skill: ') + name);
485
- }
486
- } else if (newChecksums0[name]) {
487
- delete newChecksums0[name];
488
- changed = true;
489
- }
490
- }
491
- if (changed && !DRY_RUN) {
492
- fs.mkdirSync(CLAUDE_DIR, { recursive: true });
493
- fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums0, null, 2));
494
- }
495
- }
496
-
497
- // 1. Scan package skills
498
- if (!fs.existsSync(PACKAGE_SKILLS_DIR)) {
499
- if (!SILENT && !STARTUP) {
500
- console.error('Error: skills/ directory not found in package. Package may be corrupted.');
501
- }
502
- process.exit(1);
503
- }
504
-
505
- const skillNames = fs.readdirSync(PACKAGE_SKILLS_DIR, { withFileTypes: true })
506
- .filter((e) => e.isDirectory())
507
- .map((e) => e.name)
508
- .sort();
509
-
510
- if (skillNames.length === 0) {
511
- if (!SILENT && !STARTUP) console.log('No skills found in package.');
512
- process.exit(0);
513
- }
514
-
515
- // 2. Compute hashes for package skills
516
- const packageHashes = {};
517
- for (const name of skillNames) {
518
- packageHashes[name] = computeSkillHash(path.join(PACKAGE_SKILLS_DIR, name));
519
- }
520
-
521
- // 2b. Scan package hooks
522
- const hookFilenames = fs.existsSync(PACKAGE_HOOKS_DIR)
523
- ? fs.readdirSync(PACKAGE_HOOKS_DIR).filter((f) => f.endsWith('.js')).sort()
524
- : [];
525
-
526
- const packageHookHashes = {};
527
- for (const filename of hookFilenames) {
528
- packageHookHashes[filename] = computeFileHash(path.join(PACKAGE_HOOKS_DIR, filename));
529
- }
530
-
531
- // 3. Load existing checksums
532
- let storedChecksums = {};
533
- if (fs.existsSync(CHECKSUMS_FILE)) {
534
- try {
535
- storedChecksums = JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8'));
536
- } catch (_) {
537
- storedChecksums = {};
538
- }
539
- }
540
-
541
- // 4. Detect local modifications and needed updates
542
- // Real files live in AGENTS_DIR; fall back to SKILLS_DIR for pre-migration installs.
543
- const modifiedSkills = [];
544
- const needsUpdate = [];
545
- for (const name of skillNames) {
546
- const agentsSkillDir = path.join(AGENTS_DIR, name);
547
- let installedDir = null;
548
- if (fs.existsSync(agentsSkillDir)) {
549
- installedDir = agentsSkillDir;
550
- } else {
551
- const legacyDir = path.join(SKILLS_DIR, name);
552
- if (isRealDir(legacyDir)) {
553
- installedDir = legacyDir; // old-style install, will be migrated on next write
554
- }
555
- }
556
- if (!installedDir) {
557
- needsUpdate.push(name);
558
- continue;
559
- }
560
- const currentHash = computeSkillHash(installedDir);
561
- if (!storedChecksums[name]) {
562
- // No stored checksum — skill exists but wasn't installed by us.
563
- // Treat as locally modified if it differs from the package version.
564
- if (currentHash !== packageHashes[name]) {
565
- modifiedSkills.push(name);
566
- needsUpdate.push(name);
567
- }
568
- } else if (currentHash !== storedChecksums[name]) {
569
- // Stored checksum exists but current doesn't match — user modified it
570
- modifiedSkills.push(name);
571
- if (currentHash !== packageHashes[name]) {
572
- needsUpdate.push(name);
573
- }
574
- } else if (currentHash !== packageHashes[name]) {
575
- // Current matches stored but differs from package — upstream update
576
- needsUpdate.push(name);
577
- }
578
- }
579
-
580
- // 4b. Detect local modifications and needed updates for hooks
581
- const modifiedHooks = [];
582
- const hooksNeedingUpdate = [];
583
- for (const filename of hookFilenames) {
584
- const installedFile = path.join(HOOKS_DIR, filename);
585
- const key = 'hook:' + filename;
586
- if (!fs.existsSync(installedFile)) {
587
- hooksNeedingUpdate.push(filename);
588
- continue;
589
- }
590
- const currentHash = computeFileHash(installedFile);
591
- if (!storedChecksums[key]) {
592
- if (currentHash !== packageHookHashes[filename]) {
593
- modifiedHooks.push(filename);
594
- hooksNeedingUpdate.push(filename);
595
- }
596
- } else if (currentHash !== storedChecksums[key]) {
597
- modifiedHooks.push(filename);
598
- if (storedChecksums[key] !== packageHookHashes[filename]) {
599
- hooksNeedingUpdate.push(filename);
600
- }
601
- } else if (currentHash !== packageHookHashes[filename]) {
602
- hooksNeedingUpdate.push(filename);
603
- }
604
- }
605
-
606
- // 5. Silent mode no output at all
607
- if (SILENT) {
608
- if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
609
- hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
610
- process.exit(0);
611
- }
612
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
613
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
614
- const newChecksums = Object.assign({}, storedChecksums);
615
- for (const name of skillNames) {
616
- if (modifiedSkills.includes(name)) continue;
617
- if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) continue;
618
- installSkillFiles(name);
619
- newChecksums[name] = packageHashes[name];
620
- }
621
- fs.mkdirSync(HOOKS_DIR, { recursive: true });
622
- for (const filename of hookFilenames) {
623
- if (modifiedHooks.includes(filename)) continue;
624
- if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
625
- fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
626
- newChecksums['hook:' + filename] = packageHookHashes[filename];
627
- }
628
- fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
629
- process.exit(0);
630
- }
631
-
632
- // 6. Startup mode non-interactive, backup + merge context for Claude Code
633
- if (STARTUP) {
634
- if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
635
- hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
636
- process.exit(0);
637
- }
638
-
639
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
640
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
641
- const newChecksums = Object.assign({}, storedChecksums);
642
- const updated = []; // display labels with version transitions
643
- const mergeNeeded = []; // plain names (used in merge instructions)
644
- const mergeNeededLabels = []; // display labels with version transitions
645
-
646
- for (const name of skillNames) {
647
- const newV = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
648
- if (modifiedSkills.includes(name)) {
649
- // Backup local version, install new version
650
- const oldV = getInstalledSkillVersion(name);
651
- backupSkill(name);
652
- installSkillFiles(name);
653
- newChecksums[name] = packageHashes[name];
654
- mergeNeeded.push(name);
655
- mergeNeededLabels.push(withVersion(name, oldV, newV));
656
- continue;
657
- }
658
- if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) continue;
659
- const oldV = getInstalledSkillVersion(name);
660
- installSkillFiles(name);
661
- newChecksums[name] = packageHashes[name];
662
- updated.push(withVersion(name, oldV, newV));
663
- }
664
-
665
- // Install hooks
666
- fs.mkdirSync(HOOKS_DIR, { recursive: true });
667
- const updatedHooks = [];
668
- const mergeNeededHooks = [];
669
- const mergeNeededHookLabels = [];
670
-
671
- for (const filename of hookFilenames) {
672
- const newV = getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename));
673
- if (modifiedHooks.includes(filename)) {
674
- const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
675
- backupHook(filename);
676
- fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
677
- newChecksums['hook:' + filename] = packageHookHashes[filename];
678
- mergeNeededHooks.push(filename);
679
- mergeNeededHookLabels.push(withVersion(filename, oldV, newV));
680
- continue;
681
- }
682
- if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
683
- const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
684
- fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
685
- newChecksums['hook:' + filename] = packageHookHashes[filename];
686
- updatedHooks.push(withVersion(filename, oldV, newV));
687
- }
688
-
689
- setupRepoSearchNudgeHook();
690
-
691
- fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
692
-
693
- // Build output for Claude Code
694
- const output = {};
695
- const msgParts = [];
696
-
697
- if (updated.length > 0) {
698
- msgParts.push('estack: updated ' + updated.join(', '));
699
- }
700
-
701
- if (updatedHooks.length > 0) {
702
- msgParts.push('estack: updated hooks ' + updatedHooks.join(', '));
703
- }
704
-
705
- if (mergeNeeded.length > 0) {
706
- const backupPath = BACKUP_DIR.replace(HOME, '~');
707
- msgParts.push(
708
- 'estack: updated ' + mergeNeededLabels.join(', ') +
709
- ' (local changes backed up to ' + backupPath + ')'
710
- );
711
- output.additionalContext =
712
- 'estack skills were updated but the user had local modifications to: ' +
713
- mergeNeeded.join(', ') + '. ' +
714
- 'Their previous versions are saved at ' + BACKUP_DIR + '. ' +
715
- 'The new upstream versions are now installed at ' + AGENTS_DIR + ' ' +
716
- '(symlinked from ' + SKILLS_DIR + '). ' +
717
- 'Offer to merge their customizations from the backup into the updated versions. ' +
718
- 'To merge: read both the backup version and the new version of each skill, ' +
719
- 'identify the user\'s changes, and apply them to the new version where compatible.';
720
- }
721
-
722
- if (mergeNeededHooks.length > 0) {
723
- const backupPath = BACKUP_DIR.replace(HOME, '~');
724
- msgParts.push(
725
- 'estack: updated hooks ' + mergeNeededHookLabels.join(', ') +
726
- ' (local changes backed up to ' + backupPath + '/hooks/)'
727
- );
728
- const existingContext = output.additionalContext ? output.additionalContext + ' ' : '';
729
- output.additionalContext =
730
- existingContext +
731
- 'estack hooks were updated but the user had local modifications to: ' +
732
- mergeNeededHooks.join(', ') + '. ' +
733
- 'Their previous versions are saved at ' + path.join(BACKUP_DIR, 'hooks') + '. ' +
734
- 'The new upstream versions are now installed at ' + HOOKS_DIR + '.';
735
- }
736
-
737
- if (msgParts.length > 0) {
738
- output.systemMessage = msgParts.join('\n');
739
- }
740
-
741
- if (Object.keys(output).length > 0) {
742
- console.log(JSON.stringify(output));
743
- }
744
- process.exit(0);
745
- }
746
-
747
- // 7. Interactive mode prompt if modifications detected
748
- let modifiedAction = null; // 'overwrite', 'skip', or 'merge'
749
-
750
- if (modifiedSkills.length > 0 || modifiedHooks.length > 0) {
751
- console.log('\nThe following items have been modified locally:');
752
- if (modifiedSkills.length > 0) {
753
- console.log(' Skills:');
754
- for (const name of modifiedSkills) {
755
- console.log(' - ' + name);
756
- }
757
- }
758
- if (modifiedHooks.length > 0) {
759
- console.log(' Hooks:');
760
- for (const filename of modifiedHooks) {
761
- console.log(' - ' + filename);
762
- }
763
- }
764
-
765
- if (DRY_RUN) {
766
- console.log('\n[dry run] Would prompt: overwrite / skip / merge / abort');
767
- console.log('[dry run] Showing what would happen with default overwrite...');
768
- modifiedAction = 'overwrite';
769
- } else {
770
- console.log('\nChoose an action:');
771
- console.log(' [o] Overwrite all (replace with latest)');
772
- console.log(' [s] Skip all (keep local versions)');
773
- console.log(' [m] Merge (backup local, install new, merge in Claude Code)');
774
- console.log(' [a] Abort (cancel installation)');
775
- console.log('');
776
-
777
- const answer = await promptChar('Your choice (o/s/m/a): ');
778
-
779
- if (answer === 'a') {
780
- console.log('Installation aborted.');
781
- process.exit(0);
782
- } else if (answer === 's') {
783
- modifiedAction = 'skip';
784
- } else if (answer === 'm') {
785
- modifiedAction = 'merge';
786
- } else if (answer === 'o') {
787
- modifiedAction = 'overwrite';
788
- } else {
789
- console.log('Invalid choice. Installation aborted.');
790
- process.exit(1);
791
- }
792
- }
793
- }
794
-
795
- // 8. Install skills
796
- if (!DRY_RUN) {
797
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
798
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
799
- }
800
- const newChecksums = Object.assign({}, storedChecksums);
801
- let installedCount = 0;
802
- const mergedSkills = [];
803
-
804
- for (const name of skillNames) {
805
- if (modifiedSkills.includes(name)) {
806
- if (modifiedAction === 'skip') {
807
- console.log(' Skipped ' + name + ' (local modifications preserved)');
808
- const currentHash = computeSkillHash(path.join(AGENTS_DIR, name)) ||
809
- computeSkillHash(path.join(SKILLS_DIR, name));
810
- if (currentHash) newChecksums[name] = currentHash;
811
- continue;
812
- }
813
- if (modifiedAction === 'merge') {
814
- if (!DRY_RUN) backupSkill(name);
815
- mergedSkills.push(name);
816
- console.log((DRY_RUN ? ' [dry run] Would back up ' : ' Backed up ') + name + ' → ~/.estack-backup/' + name);
817
- }
818
- // overwrite or merge — fall through to install
819
- } else if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) {
820
- // Already installed and up-to-date
821
- if (DRY_RUN) console.log(' [dry run] Up to date (no change): ' + name);
822
- continue;
823
- }
824
- const isUpdate = fs.existsSync(path.join(AGENTS_DIR, name)) ||
825
- isRealDir(path.join(SKILLS_DIR, name));
826
- const label = withVersion(name,
827
- isUpdate ? getInstalledSkillVersion(name) : null,
828
- getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name)));
829
- if (!DRY_RUN) installSkillFiles(name);
830
- newChecksums[name] = packageHashes[name];
831
- installedCount++;
832
- if (DRY_RUN) {
833
- console.log(' [dry run] Would ' + (isUpdate ? 'update ' : 'install ') + label);
834
- } else {
835
- console.log(' Installed ' + label);
836
- }
837
- }
838
-
839
- // 8b. Install hooks
840
- if (!DRY_RUN) fs.mkdirSync(HOOKS_DIR, { recursive: true });
841
- let installedHookCount = 0;
842
- const mergedHooks = [];
843
-
844
- for (const filename of hookFilenames) {
845
- if (modifiedHooks.includes(filename)) {
846
- if (modifiedAction === 'skip') {
847
- console.log(' Skipped hook ' + filename + ' (local modifications preserved)');
848
- const currentHash = computeFileHash(path.join(HOOKS_DIR, filename));
849
- if (currentHash) newChecksums['hook:' + filename] = currentHash;
850
- continue;
851
- }
852
- if (modifiedAction === 'merge') {
853
- if (!DRY_RUN) backupHook(filename);
854
- mergedHooks.push(filename);
855
- console.log((DRY_RUN ? ' [dry run] Would back up hook ' : ' Backed up hook ') + filename + ' → ~/.estack-backup/hooks/' + filename);
856
- }
857
- // overwrite or merge — fall through to install
858
- } else if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) {
859
- // Already installed and up-to-date
860
- if (DRY_RUN) console.log(' [dry run] Up to date (no change): hook ' + filename);
861
- continue;
862
- }
863
- const isHookUpdate = fs.existsSync(path.join(HOOKS_DIR, filename));
864
- const hookLabel = withVersion(filename,
865
- isHookUpdate ? getHookVersion(path.join(HOOKS_DIR, filename)) : null,
866
- getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename)));
867
- if (!DRY_RUN) fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
868
- newChecksums['hook:' + filename] = packageHookHashes[filename];
869
- installedHookCount++;
870
- if (DRY_RUN) {
871
- console.log(' [dry run] Would ' + (isHookUpdate ? 'update hook ' : 'install hook ') + hookLabel);
872
- } else {
873
- console.log(' Installed hook ' + hookLabel);
874
- }
875
- }
876
-
877
- // 9. Write checksums
878
- if (!DRY_RUN) fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
879
-
880
- // 10. Setup startup hook and repo-search nudge hook
881
- // In dry-run these inspect settings.json read-only and report would-be action.
882
- const hookInstalled = setupStartupHook(DRY_RUN);
883
- const nudgeHookInstalled = setupRepoSearchNudgeHook(DRY_RUN);
884
-
885
- // 11. Summary output
886
- if (DRY_RUN) {
887
- console.log('\n[dry run] No files were changed. Run with --install to apply.\n');
888
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.agents/skills/ (linked from ~/.claude/skills/; auto-detected by any agent that reads ~/.agents/skills/)');
889
- if (installedHookCount > 0) {
890
- console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.claude/hooks/');
891
- }
892
- } else {
893
- console.log('\nestack installed successfully!\n');
894
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.agents/skills/ (symlinked from ~/.claude/skills/; auto-detected by any agent that reads ~/.agents/skills/)');
895
- if (installedHookCount > 0) {
896
- console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' installed to ~/.claude/hooks/');
897
- }
898
- }
899
- console.log('');
900
- console.log('Skills available:');
901
-
902
- for (const name of skillNames) {
903
- const desc = getSkillDescription(path.join(PACKAGE_SKILLS_DIR, name));
904
- const ver = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
905
- console.log(' /' + name + (ver ? ' v' + ver : '') + (desc ? ' — ' + desc : ''));
906
- }
907
-
908
- if (mergedSkills.length > 0) {
909
- console.log('\nLocal changes backed up for: ' + mergedSkills.join(', '));
910
- console.log('Ask Claude to merge your changes:');
911
- console.log(' "Merge my estack changes from ~/.estack-backup/"');
912
- }
913
-
914
- if (mergedHooks.length > 0) {
915
- console.log('\nLocal hook changes backed up for: ' + mergedHooks.join(', '));
916
- console.log('Backed up to ~/.estack-backup/hooks/');
917
- }
918
-
919
- if (DRY_RUN) {
920
- if (hookInstalled) {
921
- console.log('\n[dry run] Would add auto-update hook to ~/.claude/settings.json');
922
- } else {
923
- console.log('\nAuto-update hook already configured (no change).');
924
- }
925
- if (nudgeHookInstalled) {
926
- console.log('[dry run] Would register repo-search nudge hook in settings.json.');
927
- } else {
928
- console.log('Repo-search nudge hook already configured (no change).');
929
- }
930
- } else {
931
- if (hookInstalled) {
932
- console.log('\nAuto-update hook added to ~/.claude/settings.json');
933
- console.log('Skills will update automatically when you start Claude Code.');
934
- } else {
935
- console.log('\nAuto-update hook already configured.');
936
- }
937
- if (nudgeHookInstalled) {
938
- console.log('Repo-search nudge hook registered in settings.json.');
939
- }
940
- }
941
-
942
- console.log('');
943
- }
944
-
945
- main().catch((err) => {
946
- if (!SILENT && !STARTUP) {
947
- console.error('Error during installation:', err.message || err);
948
- }
949
- process.exit(1);
950
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const os = require('os');
8
+ const readline = require('readline');
9
+
10
+ // ── Paths ──────────────────────────────────────────────────────────────────
11
+ const HOME = os.homedir();
12
+ const CLAUDE_DIR = path.join(HOME, '.claude');
13
+ const SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
14
+ const AGENTS_ROOT = path.join(HOME, '.agents');
15
+ const AGENTS_DIR = path.join(AGENTS_ROOT, 'skills');
16
+ const BACKUP_DIR = path.join(HOME, '.estack-backup');
17
+ const CHECKSUMS_FILE = path.join(CLAUDE_DIR, '.estack-checksums.json');
18
+ const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
19
+ const PACKAGE_SKILLS_DIR = path.join(__dirname, '..', 'skills');
20
+ const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
21
+ const PACKAGE_HOOKS_DIR = path.join(__dirname, '..', 'hooks');
22
+
23
+ // ── Migrate backup dir from old location (inside .claude) to user root ──────
24
+ (function migrateBackupDir() {
25
+ const OLD_BACKUP_DIR = path.join(CLAUDE_DIR, '.estack-backup');
26
+ if (!fs.existsSync(OLD_BACKUP_DIR)) return;
27
+ if (fs.existsSync(BACKUP_DIR)) return; // new location already exists, leave both alone
28
+ const silent = process.argv.includes('--silent');
29
+ const isDryRun = process.argv.includes('--dry-run') ||
30
+ (!__dirname.includes('node_modules') && !process.argv.includes('--install'));
31
+ if (isDryRun) {
32
+ if (!silent) {
33
+ process.stderr.write(
34
+ 'estack: [dry run] Would move backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
35
+ );
36
+ }
37
+ return;
38
+ }
39
+ try {
40
+ fs.renameSync(OLD_BACKUP_DIR, BACKUP_DIR);
41
+ if (!silent) {
42
+ process.stderr.write(
43
+ 'estack: moved backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
44
+ );
45
+ }
46
+ } catch (e) {
47
+ // rename across drives/filesystems — fall back to copy+delete
48
+ try {
49
+ copyDirRaw(OLD_BACKUP_DIR, BACKUP_DIR);
50
+ removeDirRaw(OLD_BACKUP_DIR);
51
+ if (!silent) {
52
+ process.stderr.write(
53
+ 'estack: migrated backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
54
+ );
55
+ }
56
+ } catch (e2) {
57
+ process.stderr.write(
58
+ 'estack: WARNING — could not migrate backup dir from ' + OLD_BACKUP_DIR +
59
+ ' to ' + BACKUP_DIR + ': ' + e2.message + '\n'
60
+ );
61
+ }
62
+ }
63
+ })();
64
+
65
+ // ── Migrate skills from ~/.agents/<name> (v1.0.23 layout) to ~/.agents/skills/ ──
66
+ (function migrateAgentsLayout() {
67
+ let strays;
68
+ try {
69
+ // statSync guards against ~/.agents existing as a plain file
70
+ if (!fs.existsSync(AGENTS_ROOT) || !fs.statSync(AGENTS_ROOT).isDirectory()) return;
71
+ strays = fs.readdirSync(AGENTS_ROOT, { withFileTypes: true })
72
+ .filter((e) => e.isDirectory() && e.name.startsWith('estack-'));
73
+ } catch (_) {
74
+ return; // unreadable — let main() surface a real error if it matters
75
+ }
76
+ if (strays.length === 0) return;
77
+ const silent = process.argv.includes('--silent');
78
+ const isDryRun = process.argv.includes('--dry-run') ||
79
+ (!__dirname.includes('node_modules') && !process.argv.includes('--install'));
80
+ if (isDryRun) {
81
+ if (!silent) {
82
+ process.stderr.write(
83
+ 'estack: [dry run] Would move ' + strays.length + ' skill(s) from ~/.agents/ to ~/.agents/skills/\n'
84
+ );
85
+ }
86
+ return;
87
+ }
88
+ try {
89
+ fs.mkdirSync(AGENTS_DIR, { recursive: true });
90
+ } catch (err) {
91
+ process.stderr.write(
92
+ 'estack: WARNING — could not create ~/.agents/skills/: ' + err.message + '\n'
93
+ );
94
+ return;
95
+ }
96
+ for (const e of strays) {
97
+ const oldPath = path.join(AGENTS_ROOT, e.name);
98
+ const newPath = path.join(AGENTS_DIR, e.name);
99
+ try {
100
+ if (fs.existsSync(newPath)) {
101
+ // already migrated — drop the stale copy at the old location
102
+ fs.rmSync(oldPath, { recursive: true, force: true });
103
+ } else {
104
+ try {
105
+ fs.renameSync(oldPath, newPath);
106
+ } catch (_) {
107
+ copyDirRaw(oldPath, newPath);
108
+ removeDirRaw(oldPath);
109
+ }
110
+ }
111
+ // re-point the live symlink — the old junction now dangles
112
+ ensureSymlink(newPath, path.join(SKILLS_DIR, e.name));
113
+ } catch (err) {
114
+ process.stderr.write(
115
+ 'estack: WARNING — could not migrate ' + e.name + ' to ~/.agents/skills/: ' + err.message + '\n'
116
+ );
117
+ }
118
+ }
119
+ if (!silent) {
120
+ process.stderr.write(
121
+ 'estack: moved ' + strays.length + ' skill(s) from ~/.agents/ to ~/.agents/skills/\n'
122
+ );
123
+ }
124
+ })();
125
+
126
+ function copyDirRaw(src, dest) {
127
+ fs.mkdirSync(dest, { recursive: true });
128
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
129
+ const s = path.join(src, entry.name);
130
+ const d = path.join(dest, entry.name);
131
+ if (entry.isDirectory()) copyDirRaw(s, d);
132
+ else fs.copyFileSync(s, d);
133
+ }
134
+ }
135
+
136
+ function removeDirRaw(dir) {
137
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
138
+ const full = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) removeDirRaw(full);
140
+ else fs.unlinkSync(full);
141
+ }
142
+ fs.rmdirSync(dir);
143
+ }
144
+
145
+ function isSymlink(p) {
146
+ try { return fs.lstatSync(p).isSymbolicLink(); } catch (_) { return false; }
147
+ }
148
+
149
+ // True only for a real directory at p — not a symlink to one, not a file.
150
+ function isRealDir(p) {
151
+ try {
152
+ const stat = fs.lstatSync(p);
153
+ return stat.isDirectory() && !stat.isSymbolicLink();
154
+ } catch (_) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // Creates (or updates) a directory symlink at linkPath pointing to target.
160
+ // On Windows uses 'junction' (no elevation required); on Unix uses 'dir'.
161
+ function ensureSymlink(target, linkPath) {
162
+ try {
163
+ const stat = fs.lstatSync(linkPath);
164
+ if (stat.isSymbolicLink()) {
165
+ if (path.resolve(fs.readlinkSync(linkPath)) === path.resolve(target)) return;
166
+ fs.unlinkSync(linkPath);
167
+ } else {
168
+ // real dir, plain file, or anything else occupying the link path
169
+ fs.rmSync(linkPath, { recursive: true, force: true });
170
+ }
171
+ } catch (_) {}
172
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
173
+ const type = process.platform === 'win32' ? 'junction' : 'dir';
174
+ fs.symlinkSync(target, linkPath, type);
175
+ }
176
+
177
+ // ── Flags ──────────────────────────────────────────────────────────────────
178
+ const SILENT = process.argv.includes('--silent');
179
+ const STARTUP = process.argv.includes('--startup');
180
+ // When run directly from the repo (not via npx/node_modules), default to dry-run
181
+ // so local testing never silently clobbers the live ~/.claude/skills install.
182
+ // Pass --install to actually write files, or --dry-run to force preview mode.
183
+ const IS_LOCAL = !__dirname.includes('node_modules');
184
+ const DRY_RUN = process.argv.includes('--dry-run') ||
185
+ (IS_LOCAL && !process.argv.includes('--install'));
186
+
187
+ // ── Deprecated skills ──────────────────────────────────────────────────────
188
+ // Skills that were renamed or removed. The installer removes these on every
189
+ // run so users don't end up with both the old and new name installed.
190
+ const DEPRECATED_SKILLS = [
191
+ 'estack-prompt-builder', // renamed to estack-prompt-builder-coach
192
+ ];
193
+
194
+ // ── Helpers ────────────────────────────────────────────────────────────────
195
+
196
+ const HASH_IGNORE_DIRS = new Set(['__pycache__', '.git', 'node_modules']);
197
+ const HASH_IGNORE_EXTS = new Set(['.pyc', '.pyo']);
198
+
199
+ // Files placed by the user inside a skill folder that must never be overwritten
200
+ // by an update. The installer saves their contents before wiping and restores
201
+ // them after the copy.
202
+ const USER_DATA_FILENAMES = new Set(['.env']);
203
+
204
+ function walkDir(dir, base) {
205
+ base = base || dir;
206
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
207
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0
208
+ );
209
+ const files = [];
210
+ for (const entry of entries) {
211
+ const full = path.join(dir, entry.name);
212
+ if (entry.isDirectory()) {
213
+ if (!HASH_IGNORE_DIRS.has(entry.name)) files.push(...walkDir(full, base));
214
+ } else if (!HASH_IGNORE_EXTS.has(path.extname(entry.name))) {
215
+ files.push(path.relative(base, full));
216
+ }
217
+ }
218
+ return files;
219
+ }
220
+
221
+ function computeFileHash(filePath) {
222
+ if (!fs.existsSync(filePath)) return null;
223
+ const hash = crypto.createHash('sha256');
224
+ const raw = fs.readFileSync(filePath);
225
+ hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
226
+ return hash.digest('hex');
227
+ }
228
+
229
+ function computeSkillHash(skillDir) {
230
+ // statSync (not lstat) so symlinked dirs hash their contents; plain files → null
231
+ try {
232
+ if (!fs.statSync(skillDir).isDirectory()) return null;
233
+ } catch (_) {
234
+ return null;
235
+ }
236
+ const hash = crypto.createHash('sha256');
237
+ const files = walkDir(skillDir, skillDir);
238
+ for (const relPath of files) {
239
+ const fullPath = path.join(skillDir, relPath);
240
+ const raw = fs.readFileSync(fullPath);
241
+ hash.update(relPath.replace(/\\/g, '/'));
242
+ hash.update(Buffer.from(raw.toString('utf8').replace(/\r\n/g, '\n')));
243
+ }
244
+ return hash.digest('hex');
245
+ }
246
+
247
+ function copyDir(src, dest) {
248
+ if (fs.existsSync(dest)) {
249
+ fs.rmSync(dest, { recursive: true, force: true });
250
+ }
251
+ fs.cpSync(src, dest, { recursive: true });
252
+ }
253
+
254
+ function backupSkill(name) {
255
+ const agentsDir = path.join(AGENTS_DIR, name);
256
+ const installedDir = fs.existsSync(agentsDir) ? agentsDir : path.join(SKILLS_DIR, name);
257
+ if (!fs.existsSync(installedDir)) return;
258
+ fs.mkdirSync(BACKUP_DIR, { recursive: true });
259
+ copyDir(installedDir, path.join(BACKUP_DIR, name));
260
+ }
261
+
262
+ function backupHook(filename) {
263
+ const installedFile = path.join(HOOKS_DIR, filename);
264
+ if (!fs.existsSync(installedFile)) return;
265
+ const dest = path.join(BACKUP_DIR, 'hooks', filename);
266
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
267
+ fs.copyFileSync(installedFile, dest);
268
+ }
269
+
270
+ function promptChar(question) {
271
+ if (!process.stdin.isTTY) {
272
+ // Non-interactive environment — read a line from piped stdin
273
+ return new Promise((resolve) => {
274
+ let resolved = false;
275
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
276
+ rl.question(question, (answer) => {
277
+ if (!resolved) {
278
+ resolved = true;
279
+ rl.close();
280
+ resolve((answer || '').toLowerCase().trim()[0] || '');
281
+ }
282
+ });
283
+ // If stdin is already closed, default to abort
284
+ rl.once('close', () => {
285
+ if (!resolved) {
286
+ resolved = true;
287
+ resolve('a');
288
+ }
289
+ });
290
+ });
291
+ }
292
+ return new Promise((resolve) => {
293
+ process.stdout.write(question);
294
+ process.stdin.setRawMode(true);
295
+ process.stdin.resume();
296
+ process.stdin.once('data', (chunk) => {
297
+ const char = chunk.toString().toLowerCase().trim()[0] || '';
298
+ try { process.stdin.setRawMode(false); } catch (_) {}
299
+ process.stdin.pause();
300
+ process.stdout.write('\n');
301
+ resolve(char);
302
+ });
303
+ });
304
+ }
305
+
306
+ function getSkillDescription(skillDir) {
307
+ const skillMd = path.join(skillDir, 'SKILL.md');
308
+ if (!fs.existsSync(skillMd)) return '';
309
+ const content = fs.readFileSync(skillMd, 'utf8');
310
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
311
+ if (!frontmatterMatch) return '';
312
+ const fm = frontmatterMatch[1];
313
+ const singleLine = fm.match(/^description:\s*(\S.*)$/m);
314
+ if (singleLine && !/^[>|]/.test(singleLine[1])) return singleLine[1].trim();
315
+ const multiLine = fm.match(/^description:\s*[>|][->+]?\r?\n((?:[ \t]+.*\r?\n?)+)/m);
316
+ if (multiLine) {
317
+ return multiLine[1].replace(/\s+/g, ' ').trim();
318
+ }
319
+ return '';
320
+ }
321
+
322
+ // Per-skill version from SKILL.md frontmatter (`version: x.y.z`).
323
+ // Versions are the human-readable label; content hashes remain the
324
+ // update-detection source of truth (scripts/check-versions.cjs keeps them in sync).
325
+ function getSkillVersion(skillDir) {
326
+ const skillMd = path.join(skillDir, 'SKILL.md');
327
+ if (!fs.existsSync(skillMd)) return null;
328
+ const content = fs.readFileSync(skillMd, 'utf8');
329
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
330
+ if (!frontmatterMatch) return null;
331
+ const m = frontmatterMatch[1].match(/^version:\s*(\S+)\s*$/m);
332
+ return m ? m[1] : null;
333
+ }
334
+
335
+ // Version of the currently installed copy of a skill (agents dir, falling
336
+ // back to the legacy skills dir for pre-migration installs).
337
+ function getInstalledSkillVersion(name) {
338
+ const agentsDir = path.join(AGENTS_DIR, name);
339
+ if (fs.existsSync(agentsDir)) return getSkillVersion(agentsDir);
340
+ return getSkillVersion(path.join(SKILLS_DIR, name));
341
+ }
342
+
343
+ // Per-hook version from a `// @version x.y.z` comment near the top.
344
+ function getHookVersion(filePath) {
345
+ if (!fs.existsSync(filePath)) return null;
346
+ const m = fs.readFileSync(filePath, 'utf8').match(/^\/\/ @version\s+(\S+)\s*$/m);
347
+ return m ? m[1] : null;
348
+ }
349
+
350
+ // "name (1.0.0 → 1.1.0)" for updates, "name (v1.1.0)" for fresh installs.
351
+ function withVersion(name, oldV, newV) {
352
+ if (oldV && newV && oldV !== newV) return name + ' (' + oldV + ' → ' + newV + ')';
353
+ if (newV) return name + ' (v' + newV + ')';
354
+ return name;
355
+ }
356
+
357
+ // Collect user-owned files (e.g. .env) from an installed skill dir so they can
358
+ // be restored after a fresh copy wipes the directory.
359
+ function collectUserDataFiles(dir) {
360
+ const saved = new Map();
361
+ if (!fs.existsSync(dir)) return saved;
362
+ function walk(cur) {
363
+ for (const entry of fs.readdirSync(cur, { withFileTypes: true })) {
364
+ const full = path.join(cur, entry.name);
365
+ if (entry.isDirectory()) {
366
+ walk(full);
367
+ } else if (USER_DATA_FILENAMES.has(entry.name)) {
368
+ saved.set(path.relative(dir, full), fs.readFileSync(full));
369
+ }
370
+ }
371
+ }
372
+ walk(dir);
373
+ return saved;
374
+ }
375
+
376
+ // Copies a skill to ~/.agents/skills/<name> and creates/updates the symlink at ~/.claude/skills/<name>.
377
+ // If a real (non-symlink) directory already exists at the skills path, it is removed first.
378
+ // User-owned files (e.g. .env) present in the installed copy are preserved across the update.
379
+ function installSkillFiles(name) {
380
+ const agentsSkillDir = path.join(AGENTS_DIR, name);
381
+ const skillsLinkDir = path.join(SKILLS_DIR, name);
382
+ if (!isSymlink(skillsLinkDir) && fs.existsSync(skillsLinkDir)) {
383
+ fs.rmSync(skillsLinkDir, { recursive: true, force: true });
384
+ }
385
+ const userDataFiles = collectUserDataFiles(agentsSkillDir);
386
+ copyDir(path.join(PACKAGE_SKILLS_DIR, name), agentsSkillDir);
387
+ for (const [rel, content] of userDataFiles) {
388
+ const dest = path.join(agentsSkillDir, rel);
389
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
390
+ fs.writeFileSync(dest, content);
391
+ }
392
+ ensureSymlink(agentsSkillDir, skillsLinkDir);
393
+ }
394
+
395
+ // ── Hook setup ─────────────────────────────────────────────────────────────
396
+
397
+ // Returns true if the hook was added (or would be added in dryRun), false if
398
+ // it was already configured. In dryRun mode nothing is written to disk.
399
+ function setupStartupHook(dryRun) {
400
+ let settings = {};
401
+ if (fs.existsSync(SETTINGS_FILE)) {
402
+ try {
403
+ settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
404
+ } catch (_) {
405
+ settings = {};
406
+ }
407
+ }
408
+
409
+ if (settings.hooks && settings.hooks.SessionStart) {
410
+ const existing = settings.hooks.SessionStart;
411
+ for (const group of existing) {
412
+ if (group.matcher === 'startup' && group.hooks) {
413
+ for (const hook of group.hooks) {
414
+ if (hook.command && hook.command.includes('elliot-stack@latest --startup')) {
415
+ return false;
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ if (dryRun) return true;
423
+
424
+ if (!settings.hooks) settings.hooks = {};
425
+ if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
426
+
427
+ let startupGroup = settings.hooks.SessionStart.find(
428
+ (g) => g.matcher === 'startup'
429
+ );
430
+ if (!startupGroup) {
431
+ startupGroup = { matcher: 'startup', hooks: [] };
432
+ settings.hooks.SessionStart.push(startupGroup);
433
+ }
434
+
435
+ startupGroup.hooks.push({
436
+ type: 'command',
437
+ command: 'npx --yes elliot-stack@latest --startup',
438
+ });
439
+
440
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
441
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
442
+ return true;
443
+ }
444
+
445
+ // Returns true if the hook was added (or would be added in dryRun), false if
446
+ // it was already configured. In dryRun mode nothing is written to disk.
447
+ function setupRepoSearchNudgeHook(dryRun) {
448
+ let settings = {};
449
+ if (fs.existsSync(SETTINGS_FILE)) {
450
+ try {
451
+ settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
452
+ } catch (_) {
453
+ settings = {};
454
+ }
455
+ }
456
+
457
+ if (settings.hooks && settings.hooks.PostToolUse) {
458
+ for (const group of settings.hooks.PostToolUse) {
459
+ if (group.matcher === 'WebFetch|WebSearch' && group.hooks) {
460
+ for (const hook of group.hooks) {
461
+ if (hook.command && hook.command.includes('repo-search-nudge.js')) {
462
+ return false;
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ if (dryRun) return true;
470
+
471
+ if (!settings.hooks) settings.hooks = {};
472
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
473
+
474
+ settings.hooks.PostToolUse.push({
475
+ matcher: 'WebFetch|WebSearch',
476
+ hooks: [{
477
+ type: 'command',
478
+ command: `node "${path.join(HOOKS_DIR, 'repo-search-nudge.js').replace(/\\/g, '/')}"`,
479
+ timeout: 5,
480
+ }],
481
+ });
482
+
483
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
484
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
485
+ return true;
486
+ }
487
+
488
+ // ── Main ────────────────────────────────────────────────────────────────────
489
+
490
+ async function main() {
491
+ // 0. Remove deprecated skills (renamed/deleted from the package)
492
+ if (fs.existsSync(SKILLS_DIR) || fs.existsSync(AGENTS_DIR)) {
493
+ const newChecksums0 = fs.existsSync(CHECKSUMS_FILE)
494
+ ? (() => { try { return JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8')); } catch (_) { return {}; } })()
495
+ : {};
496
+ let changed = false;
497
+ for (const name of DEPRECATED_SKILLS) {
498
+ const agentsDir = path.join(AGENTS_DIR, name);
499
+ const skillsDir = path.join(SKILLS_DIR, name);
500
+ let found = false;
501
+ if (fs.existsSync(agentsDir)) {
502
+ if (!DRY_RUN) fs.rmSync(agentsDir, { recursive: true, force: true });
503
+ found = true;
504
+ }
505
+ if (fs.existsSync(skillsDir) || isSymlink(skillsDir)) {
506
+ if (!DRY_RUN) {
507
+ try { fs.unlinkSync(skillsDir); } catch (_) { fs.rmSync(skillsDir, { recursive: true, force: true }); }
508
+ }
509
+ found = true;
510
+ }
511
+ if (found) {
512
+ delete newChecksums0[name];
513
+ changed = true;
514
+ if (!SILENT && !STARTUP) {
515
+ console.log((DRY_RUN ? ' [dry run] Would remove deprecated skill: ' : ' Removed deprecated skill: ') + name);
516
+ }
517
+ } else if (newChecksums0[name]) {
518
+ delete newChecksums0[name];
519
+ changed = true;
520
+ }
521
+ }
522
+ if (changed && !DRY_RUN) {
523
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
524
+ fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums0, null, 2));
525
+ }
526
+ }
527
+
528
+ // 1. Scan package skills
529
+ if (!fs.existsSync(PACKAGE_SKILLS_DIR)) {
530
+ if (!SILENT && !STARTUP) {
531
+ console.error('Error: skills/ directory not found in package. Package may be corrupted.');
532
+ }
533
+ process.exit(1);
534
+ }
535
+
536
+ const skillNames = fs.readdirSync(PACKAGE_SKILLS_DIR, { withFileTypes: true })
537
+ .filter((e) => e.isDirectory())
538
+ .map((e) => e.name)
539
+ .sort();
540
+
541
+ if (skillNames.length === 0) {
542
+ if (!SILENT && !STARTUP) console.log('No skills found in package.');
543
+ process.exit(0);
544
+ }
545
+
546
+ // 2. Compute hashes for package skills
547
+ const packageHashes = {};
548
+ for (const name of skillNames) {
549
+ packageHashes[name] = computeSkillHash(path.join(PACKAGE_SKILLS_DIR, name));
550
+ }
551
+
552
+ // 2b. Scan package hooks
553
+ const hookFilenames = fs.existsSync(PACKAGE_HOOKS_DIR)
554
+ ? fs.readdirSync(PACKAGE_HOOKS_DIR).filter((f) => f.endsWith('.js')).sort()
555
+ : [];
556
+
557
+ const packageHookHashes = {};
558
+ for (const filename of hookFilenames) {
559
+ packageHookHashes[filename] = computeFileHash(path.join(PACKAGE_HOOKS_DIR, filename));
560
+ }
561
+
562
+ // 3. Load existing checksums
563
+ let storedChecksums = {};
564
+ if (fs.existsSync(CHECKSUMS_FILE)) {
565
+ try {
566
+ storedChecksums = JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8'));
567
+ } catch (_) {
568
+ storedChecksums = {};
569
+ }
570
+ }
571
+
572
+ // 4. Detect local modifications and needed updates
573
+ // Real files live in AGENTS_DIR; fall back to SKILLS_DIR for pre-migration installs.
574
+ const modifiedSkills = [];
575
+ const needsUpdate = [];
576
+ for (const name of skillNames) {
577
+ const agentsSkillDir = path.join(AGENTS_DIR, name);
578
+ let installedDir = null;
579
+ if (fs.existsSync(agentsSkillDir)) {
580
+ installedDir = agentsSkillDir;
581
+ } else {
582
+ const legacyDir = path.join(SKILLS_DIR, name);
583
+ if (isRealDir(legacyDir)) {
584
+ installedDir = legacyDir; // old-style install, will be migrated on next write
585
+ }
586
+ }
587
+ if (!installedDir) {
588
+ needsUpdate.push(name);
589
+ continue;
590
+ }
591
+ const currentHash = computeSkillHash(installedDir);
592
+ if (!storedChecksums[name]) {
593
+ // No stored checksum — skill exists but wasn't installed by us.
594
+ // Treat as locally modified if it differs from the package version.
595
+ if (currentHash !== packageHashes[name]) {
596
+ modifiedSkills.push(name);
597
+ needsUpdate.push(name);
598
+ }
599
+ } else if (currentHash !== storedChecksums[name]) {
600
+ // Stored checksum exists but current doesn't match — user modified it
601
+ modifiedSkills.push(name);
602
+ if (currentHash !== packageHashes[name]) {
603
+ needsUpdate.push(name);
604
+ }
605
+ } else if (currentHash !== packageHashes[name]) {
606
+ // Current matches stored but differs from package — upstream update
607
+ needsUpdate.push(name);
608
+ }
609
+ }
610
+
611
+ // 4b. Detect local modifications and needed updates for hooks
612
+ const modifiedHooks = [];
613
+ const hooksNeedingUpdate = [];
614
+ for (const filename of hookFilenames) {
615
+ const installedFile = path.join(HOOKS_DIR, filename);
616
+ const key = 'hook:' + filename;
617
+ if (!fs.existsSync(installedFile)) {
618
+ hooksNeedingUpdate.push(filename);
619
+ continue;
620
+ }
621
+ const currentHash = computeFileHash(installedFile);
622
+ if (!storedChecksums[key]) {
623
+ if (currentHash !== packageHookHashes[filename]) {
624
+ modifiedHooks.push(filename);
625
+ hooksNeedingUpdate.push(filename);
626
+ }
627
+ } else if (currentHash !== storedChecksums[key]) {
628
+ modifiedHooks.push(filename);
629
+ if (storedChecksums[key] !== packageHookHashes[filename]) {
630
+ hooksNeedingUpdate.push(filename);
631
+ }
632
+ } else if (currentHash !== packageHookHashes[filename]) {
633
+ hooksNeedingUpdate.push(filename);
634
+ }
635
+ }
636
+
637
+ // 5. Silent mode — no output at all
638
+ if (SILENT) {
639
+ if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
640
+ hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
641
+ process.exit(0);
642
+ }
643
+ fs.mkdirSync(AGENTS_DIR, { recursive: true });
644
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
645
+ const newChecksums = Object.assign({}, storedChecksums);
646
+ for (const name of skillNames) {
647
+ if (modifiedSkills.includes(name)) continue;
648
+ if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) continue;
649
+ installSkillFiles(name);
650
+ newChecksums[name] = packageHashes[name];
651
+ }
652
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
653
+ for (const filename of hookFilenames) {
654
+ if (modifiedHooks.includes(filename)) continue;
655
+ if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
656
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
657
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
658
+ }
659
+ fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
660
+ process.exit(0);
661
+ }
662
+
663
+ // 6. Startup mode — non-interactive, backup + merge context for Claude Code
664
+ if (STARTUP) {
665
+ if (needsUpdate.length === 0 && modifiedSkills.length === 0 &&
666
+ hooksNeedingUpdate.length === 0 && modifiedHooks.length === 0) {
667
+ process.exit(0);
668
+ }
669
+
670
+ fs.mkdirSync(AGENTS_DIR, { recursive: true });
671
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
672
+ const newChecksums = Object.assign({}, storedChecksums);
673
+ const updated = []; // display labels with version transitions
674
+ const mergeNeeded = []; // plain names (used in merge instructions)
675
+ const mergeNeededLabels = []; // display labels with version transitions
676
+
677
+ for (const name of skillNames) {
678
+ const newV = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
679
+ if (modifiedSkills.includes(name)) {
680
+ // Backup local version, install new version
681
+ const oldV = getInstalledSkillVersion(name);
682
+ backupSkill(name);
683
+ installSkillFiles(name);
684
+ newChecksums[name] = packageHashes[name];
685
+ mergeNeeded.push(name);
686
+ mergeNeededLabels.push(withVersion(name, oldV, newV));
687
+ continue;
688
+ }
689
+ if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) continue;
690
+ const oldV = getInstalledSkillVersion(name);
691
+ installSkillFiles(name);
692
+ newChecksums[name] = packageHashes[name];
693
+ updated.push(withVersion(name, oldV, newV));
694
+ }
695
+
696
+ // Install hooks
697
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
698
+ const updatedHooks = [];
699
+ const mergeNeededHooks = [];
700
+ const mergeNeededHookLabels = [];
701
+
702
+ for (const filename of hookFilenames) {
703
+ const newV = getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename));
704
+ if (modifiedHooks.includes(filename)) {
705
+ const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
706
+ backupHook(filename);
707
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
708
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
709
+ mergeNeededHooks.push(filename);
710
+ mergeNeededHookLabels.push(withVersion(filename, oldV, newV));
711
+ continue;
712
+ }
713
+ if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
714
+ const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
715
+ fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
716
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
717
+ updatedHooks.push(withVersion(filename, oldV, newV));
718
+ }
719
+
720
+ setupRepoSearchNudgeHook();
721
+
722
+ fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
723
+
724
+ // Build output for Claude Code
725
+ const output = {};
726
+ const msgParts = [];
727
+
728
+ if (updated.length > 0) {
729
+ msgParts.push('estack: updated ' + updated.join(', '));
730
+ }
731
+
732
+ if (updatedHooks.length > 0) {
733
+ msgParts.push('estack: updated hooks ' + updatedHooks.join(', '));
734
+ }
735
+
736
+ if (mergeNeeded.length > 0) {
737
+ const backupPath = BACKUP_DIR.replace(HOME, '~');
738
+ msgParts.push(
739
+ 'estack: updated ' + mergeNeededLabels.join(', ') +
740
+ ' (local changes backed up to ' + backupPath + ')'
741
+ );
742
+ output.additionalContext =
743
+ 'estack skills were updated but the user had local modifications to: ' +
744
+ mergeNeeded.join(', ') + '. ' +
745
+ 'Their previous versions are saved at ' + BACKUP_DIR + '. ' +
746
+ 'The new upstream versions are now installed at ' + AGENTS_DIR + ' ' +
747
+ '(symlinked from ' + SKILLS_DIR + '). ' +
748
+ 'Offer to merge their customizations from the backup into the updated versions. ' +
749
+ 'To merge: read both the backup version and the new version of each skill, ' +
750
+ 'identify the user\'s changes, and apply them to the new version where compatible.';
751
+ }
752
+
753
+ if (mergeNeededHooks.length > 0) {
754
+ const backupPath = BACKUP_DIR.replace(HOME, '~');
755
+ msgParts.push(
756
+ 'estack: updated hooks ' + mergeNeededHookLabels.join(', ') +
757
+ ' (local changes backed up to ' + backupPath + '/hooks/)'
758
+ );
759
+ const existingContext = output.additionalContext ? output.additionalContext + ' ' : '';
760
+ output.additionalContext =
761
+ existingContext +
762
+ 'estack hooks were updated but the user had local modifications to: ' +
763
+ mergeNeededHooks.join(', ') + '. ' +
764
+ 'Their previous versions are saved at ' + path.join(BACKUP_DIR, 'hooks') + '. ' +
765
+ 'The new upstream versions are now installed at ' + HOOKS_DIR + '.';
766
+ }
767
+
768
+ if (msgParts.length > 0) {
769
+ output.systemMessage = msgParts.join('\n');
770
+ }
771
+
772
+ if (Object.keys(output).length > 0) {
773
+ console.log(JSON.stringify(output));
774
+ }
775
+ process.exit(0);
776
+ }
777
+
778
+ // 7. Interactive mode — prompt if modifications detected
779
+ let modifiedAction = null; // 'overwrite', 'skip', or 'merge'
780
+
781
+ if (modifiedSkills.length > 0 || modifiedHooks.length > 0) {
782
+ console.log('\nThe following items have been modified locally:');
783
+ if (modifiedSkills.length > 0) {
784
+ console.log(' Skills:');
785
+ for (const name of modifiedSkills) {
786
+ console.log(' - ' + name);
787
+ }
788
+ }
789
+ if (modifiedHooks.length > 0) {
790
+ console.log(' Hooks:');
791
+ for (const filename of modifiedHooks) {
792
+ console.log(' - ' + filename);
793
+ }
794
+ }
795
+
796
+ if (DRY_RUN) {
797
+ console.log('\n[dry run] Would prompt: overwrite / skip / merge / abort');
798
+ console.log('[dry run] Showing what would happen with default overwrite...');
799
+ modifiedAction = 'overwrite';
800
+ } else {
801
+ console.log('\nChoose an action:');
802
+ console.log(' [o] Overwrite all (replace with latest)');
803
+ console.log(' [s] Skip all (keep local versions)');
804
+ console.log(' [m] Merge (backup local, install new, merge in Claude Code)');
805
+ console.log(' [a] Abort (cancel installation)');
806
+ console.log('');
807
+
808
+ const answer = await promptChar('Your choice (o/s/m/a): ');
809
+
810
+ if (answer === 'a') {
811
+ console.log('Installation aborted.');
812
+ process.exit(0);
813
+ } else if (answer === 's') {
814
+ modifiedAction = 'skip';
815
+ } else if (answer === 'm') {
816
+ modifiedAction = 'merge';
817
+ } else if (answer === 'o') {
818
+ modifiedAction = 'overwrite';
819
+ } else {
820
+ console.log('Invalid choice. Installation aborted.');
821
+ process.exit(1);
822
+ }
823
+ }
824
+ }
825
+
826
+ // 8. Install skills
827
+ if (!DRY_RUN) {
828
+ fs.mkdirSync(AGENTS_DIR, { recursive: true });
829
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
830
+ }
831
+ const newChecksums = Object.assign({}, storedChecksums);
832
+ let installedCount = 0;
833
+ const mergedSkills = [];
834
+
835
+ for (const name of skillNames) {
836
+ if (modifiedSkills.includes(name)) {
837
+ if (modifiedAction === 'skip') {
838
+ console.log(' Skipped ' + name + ' (local modifications preserved)');
839
+ const currentHash = computeSkillHash(path.join(AGENTS_DIR, name)) ||
840
+ computeSkillHash(path.join(SKILLS_DIR, name));
841
+ if (currentHash) newChecksums[name] = currentHash;
842
+ continue;
843
+ }
844
+ if (modifiedAction === 'merge') {
845
+ if (!DRY_RUN) backupSkill(name);
846
+ mergedSkills.push(name);
847
+ console.log((DRY_RUN ? ' [dry run] Would back up ' : ' Backed up ') + name + ' ~/.estack-backup/' + name);
848
+ }
849
+ // overwrite or merge fall through to install
850
+ } else if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) {
851
+ // Already installed and up-to-date
852
+ if (DRY_RUN) console.log(' [dry run] Up to date (no change): ' + name);
853
+ continue;
854
+ }
855
+ const isUpdate = fs.existsSync(path.join(AGENTS_DIR, name)) ||
856
+ isRealDir(path.join(SKILLS_DIR, name));
857
+ const label = withVersion(name,
858
+ isUpdate ? getInstalledSkillVersion(name) : null,
859
+ getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name)));
860
+ if (!DRY_RUN) installSkillFiles(name);
861
+ newChecksums[name] = packageHashes[name];
862
+ installedCount++;
863
+ if (DRY_RUN) {
864
+ console.log(' [dry run] Would ' + (isUpdate ? 'update ' : 'install ') + label);
865
+ } else {
866
+ console.log(' Installed ' + label);
867
+ }
868
+ }
869
+
870
+ // 8b. Install hooks
871
+ if (!DRY_RUN) fs.mkdirSync(HOOKS_DIR, { recursive: true });
872
+ let installedHookCount = 0;
873
+ const mergedHooks = [];
874
+
875
+ for (const filename of hookFilenames) {
876
+ if (modifiedHooks.includes(filename)) {
877
+ if (modifiedAction === 'skip') {
878
+ console.log(' Skipped hook ' + filename + ' (local modifications preserved)');
879
+ const currentHash = computeFileHash(path.join(HOOKS_DIR, filename));
880
+ if (currentHash) newChecksums['hook:' + filename] = currentHash;
881
+ continue;
882
+ }
883
+ if (modifiedAction === 'merge') {
884
+ if (!DRY_RUN) backupHook(filename);
885
+ mergedHooks.push(filename);
886
+ console.log((DRY_RUN ? ' [dry run] Would back up hook ' : ' Backed up hook ') + filename + ' → ~/.estack-backup/hooks/' + filename);
887
+ }
888
+ // overwrite or merge fall through to install
889
+ } else if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) {
890
+ // Already installed and up-to-date
891
+ if (DRY_RUN) console.log(' [dry run] Up to date (no change): hook ' + filename);
892
+ continue;
893
+ }
894
+ const isHookUpdate = fs.existsSync(path.join(HOOKS_DIR, filename));
895
+ const hookLabel = withVersion(filename,
896
+ isHookUpdate ? getHookVersion(path.join(HOOKS_DIR, filename)) : null,
897
+ getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename)));
898
+ if (!DRY_RUN) fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
899
+ newChecksums['hook:' + filename] = packageHookHashes[filename];
900
+ installedHookCount++;
901
+ if (DRY_RUN) {
902
+ console.log(' [dry run] Would ' + (isHookUpdate ? 'update hook ' : 'install hook ') + hookLabel);
903
+ } else {
904
+ console.log(' Installed hook ' + hookLabel);
905
+ }
906
+ }
907
+
908
+ // 9. Write checksums
909
+ if (!DRY_RUN) fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums, null, 2));
910
+
911
+ // 10. Setup startup hook and repo-search nudge hook
912
+ // In dry-run these inspect settings.json read-only and report would-be action.
913
+ const hookInstalled = setupStartupHook(DRY_RUN);
914
+ const nudgeHookInstalled = setupRepoSearchNudgeHook(DRY_RUN);
915
+
916
+ // 11. Summary output
917
+ if (DRY_RUN) {
918
+ console.log('\n[dry run] No files were changed. Run with --install to apply.\n');
919
+ console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.agents/skills/ (linked from ~/.claude/skills/; auto-detected by any agent that reads ~/.agents/skills/)');
920
+ if (installedHookCount > 0) {
921
+ console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.claude/hooks/');
922
+ }
923
+ } else {
924
+ console.log('\nestack installed successfully!\n');
925
+ console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.agents/skills/ (symlinked from ~/.claude/skills/; auto-detected by any agent that reads ~/.agents/skills/)');
926
+ if (installedHookCount > 0) {
927
+ console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' installed to ~/.claude/hooks/');
928
+ }
929
+ }
930
+ console.log('');
931
+ console.log('Skills available:');
932
+
933
+ for (const name of skillNames) {
934
+ const desc = getSkillDescription(path.join(PACKAGE_SKILLS_DIR, name));
935
+ const ver = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
936
+ console.log(' /' + name + (ver ? ' v' + ver : '') + (desc ? ' — ' + desc : ''));
937
+ }
938
+
939
+ if (mergedSkills.length > 0) {
940
+ console.log('\nLocal changes backed up for: ' + mergedSkills.join(', '));
941
+ console.log('Ask Claude to merge your changes:');
942
+ console.log(' "Merge my estack changes from ~/.estack-backup/"');
943
+ }
944
+
945
+ if (mergedHooks.length > 0) {
946
+ console.log('\nLocal hook changes backed up for: ' + mergedHooks.join(', '));
947
+ console.log('Backed up to ~/.estack-backup/hooks/');
948
+ }
949
+
950
+ if (DRY_RUN) {
951
+ if (hookInstalled) {
952
+ console.log('\n[dry run] Would add auto-update hook to ~/.claude/settings.json');
953
+ } else {
954
+ console.log('\nAuto-update hook already configured (no change).');
955
+ }
956
+ if (nudgeHookInstalled) {
957
+ console.log('[dry run] Would register repo-search nudge hook in settings.json.');
958
+ } else {
959
+ console.log('Repo-search nudge hook already configured (no change).');
960
+ }
961
+ } else {
962
+ if (hookInstalled) {
963
+ console.log('\nAuto-update hook added to ~/.claude/settings.json');
964
+ console.log('Skills will update automatically when you start Claude Code.');
965
+ } else {
966
+ console.log('\nAuto-update hook already configured.');
967
+ }
968
+ if (nudgeHookInstalled) {
969
+ console.log('Repo-search nudge hook registered in settings.json.');
970
+ }
971
+ }
972
+
973
+ console.log('');
974
+ }
975
+
976
+ main().catch((err) => {
977
+ if (!SILENT && !STARTUP) {
978
+ console.error('Error during installation:', err.message || err);
979
+ }
980
+ process.exit(1);
981
+ });