elliot-stack 1.0.33 → 1.0.37
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/LICENSE +21 -21
- package/bin/install.cjs +981 -981
- package/hooks/repo-search-nudge.js +32 -32
- package/package.json +1 -1
- package/skills/estack-active-learning-tutor/SKILL.md +339 -339
- package/skills/estack-better-title/SKILL.md +64 -64
- package/skills/estack-better-title/scripts/rename.sh +55 -55
- package/skills/estack-chris-voss/SKILL.md +80 -80
- package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
- package/skills/estack-chris-voss/references/voss-principles.md +210 -210
- package/skills/estack-customer-discovery/SKILL.md +60 -60
- package/skills/estack-flight-planner/SKILL.md +332 -332
- package/skills/estack-flight-planner/references/config_schema.md +156 -156
- package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
- package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
- package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
- package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
- package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
- package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
- package/skills/estack-github-issue-tracker/SKILL.md +322 -322
- package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
- package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
- package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
- package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
- package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
- package/skills/estack-leadership-coach/SKILL.md +1 -1
- package/skills/estack-leadership-coach/adding-references.md +1 -1
- package/skills/estack-migrate-claude-session-history/SKILL.md +15 -2
- package/skills/estack-pdf-to-md/SKILL.md +1 -2
- package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
- package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
- package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
- package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
- package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
- package/skills/estack-read-claude-session-history/SKILL.md +224 -204
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
- package/skills/estack-read-claude-session-history/references/modes.md +423 -423
- package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
- package/skills/estack-repo-search/SKILL.md +67 -65
package/bin/install.cjs
CHANGED
|
@@ -1,981 +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
|
-
// 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
|
-
});
|
|
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
|
+
});
|