elliot-stack 1.0.23 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ A curated set of Claude Code skills by Elliot Drel. One command installs them al
11
11
  npx elliot-stack@latest
12
12
  ```
13
13
 
14
- This installs skills to `~/.agents/` and symlinks them into `~/.claude/skills/`, then registers a `SessionStart` hook so your skills stay up to date automatically.
14
+ This installs skills to `~/.agents/skills/` and symlinks them into `~/.claude/skills/`, then registers a `SessionStart` hook so your skills stay up to date automatically.
15
15
 
16
16
  ## Skills
17
17
 
@@ -34,10 +34,11 @@ Hooks install to `~/.claude/hooks/` and are auto-registered in your `~/.claude/s
34
34
 
35
35
  ## How it works
36
36
 
37
- - Skills install to `~/.agents/estack-*/` (symlinked from `~/.claude/skills/estack-*/`)
37
+ - Skills install to `~/.agents/skills/estack-*/` (symlinked from `~/.claude/skills/estack-*/`)
38
38
  - Hooks install to `~/.claude/hooks/` and are registered in `~/.claude/settings.json`
39
39
  - A `SessionStart` hook auto-updates both each time you open Claude Code
40
40
  - If you've made local edits to a skill or hook, the installer detects the conflict and lets you choose: overwrite, skip, or merge
41
+ - Every skill carries its own semver (`version:` in SKILL.md frontmatter; hooks use a `// @version` comment), independent of the package version — update messages show exactly what moved, e.g. `estack-chris-voss (1.0.0 → 1.1.0)`. Under the hood, updates are detected by content hash, so a change can never be missed; a release-time check (`scripts/check-versions.cjs`) guarantees every content change ships with a version bump.
41
42
 
42
43
  ## Updating
43
44
 
@@ -62,7 +63,7 @@ Run the installer straight from your checkout to preview what a real install wou
62
63
 
63
64
  ```bash
64
65
  node bin/install.cjs # dry run — previews changes, writes nothing
65
- node bin/install.cjs --install # actually sync your local edits to ~/.agents/ + ~/.claude/skills/
66
+ node bin/install.cjs --install # actually sync your local edits to ~/.agents/skills/ + ~/.claude/skills/
66
67
  ```
67
68
 
68
69
  Run from the repo, the installer **dry-runs by default** so testing never clobbers your live install. Pass `--install` once the preview looks right. (`--dry-run` forces a preview even under `npx`.)
package/bin/install.cjs CHANGED
@@ -11,7 +11,8 @@ const readline = require('readline');
11
11
  const HOME = os.homedir();
12
12
  const CLAUDE_DIR = path.join(HOME, '.claude');
13
13
  const SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
14
- const AGENTS_DIR = path.join(HOME, '.agents');
14
+ const AGENTS_ROOT = path.join(HOME, '.agents');
15
+ const AGENTS_DIR = path.join(AGENTS_ROOT, 'skills');
15
16
  const BACKUP_DIR = path.join(HOME, '.estack-backup');
16
17
  const CHECKSUMS_FILE = path.join(CLAUDE_DIR, '.estack-checksums.json');
17
18
  const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
@@ -61,6 +62,67 @@ const PACKAGE_HOOKS_DIR = path.join(__dirname, '..', 'hooks');
61
62
  }
62
63
  })();
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
+
64
126
  function copyDirRaw(src, dest) {
65
127
  fs.mkdirSync(dest, { recursive: true });
66
128
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
@@ -84,6 +146,16 @@ function isSymlink(p) {
84
146
  try { return fs.lstatSync(p).isSymbolicLink(); } catch (_) { return false; }
85
147
  }
86
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
+
87
159
  // Creates (or updates) a directory symlink at linkPath pointing to target.
88
160
  // On Windows uses 'junction' (no elevation required); on Unix uses 'dir'.
89
161
  function ensureSymlink(target, linkPath) {
@@ -92,10 +164,12 @@ function ensureSymlink(target, linkPath) {
92
164
  if (stat.isSymbolicLink()) {
93
165
  if (path.resolve(fs.readlinkSync(linkPath)) === path.resolve(target)) return;
94
166
  fs.unlinkSync(linkPath);
95
- } else if (stat.isDirectory()) {
167
+ } else {
168
+ // real dir, plain file, or anything else occupying the link path
96
169
  fs.rmSync(linkPath, { recursive: true, force: true });
97
170
  }
98
171
  } catch (_) {}
172
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
99
173
  const type = process.platform === 'win32' ? 'junction' : 'dir';
100
174
  fs.symlinkSync(target, linkPath, type);
101
175
  }
@@ -148,7 +222,12 @@ function computeFileHash(filePath) {
148
222
  }
149
223
 
150
224
  function computeSkillHash(skillDir) {
151
- if (!fs.existsSync(skillDir)) return null;
225
+ // statSync (not lstat) so symlinked dirs hash their contents; plain files → null
226
+ try {
227
+ if (!fs.statSync(skillDir).isDirectory()) return null;
228
+ } catch (_) {
229
+ return null;
230
+ }
152
231
  const hash = crypto.createHash('sha256');
153
232
  const files = walkDir(skillDir, skillDir);
154
233
  for (const relPath of files) {
@@ -223,19 +302,54 @@ function getSkillDescription(skillDir) {
223
302
  const skillMd = path.join(skillDir, 'SKILL.md');
224
303
  if (!fs.existsSync(skillMd)) return '';
225
304
  const content = fs.readFileSync(skillMd, 'utf8');
226
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
305
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
227
306
  if (!frontmatterMatch) return '';
228
307
  const fm = frontmatterMatch[1];
229
- const singleLine = fm.match(/^description:\s*(.+)$/m);
230
- if (singleLine) return singleLine[1].trim();
231
- const multiLine = fm.match(/^description:\s*>\n((?:\s+.+\n?)+)/m);
308
+ const singleLine = fm.match(/^description:\s*(\S.*)$/m);
309
+ if (singleLine && !/^[>|]/.test(singleLine[1])) return singleLine[1].trim();
310
+ const multiLine = fm.match(/^description:\s*[>|][->+]?\r?\n((?:[ \t]+.*\r?\n?)+)/m);
232
311
  if (multiLine) {
233
312
  return multiLine[1].replace(/\s+/g, ' ').trim();
234
313
  }
235
314
  return '';
236
315
  }
237
316
 
238
- // Copies a skill to ~/.agents/<name> and creates/updates the symlink at ~/.claude/skills/<name>.
317
+ // Per-skill version from SKILL.md frontmatter (`version: x.y.z`).
318
+ // Versions are the human-readable label; content hashes remain the
319
+ // update-detection source of truth (scripts/check-versions.cjs keeps them in sync).
320
+ function getSkillVersion(skillDir) {
321
+ const skillMd = path.join(skillDir, 'SKILL.md');
322
+ if (!fs.existsSync(skillMd)) return null;
323
+ const content = fs.readFileSync(skillMd, 'utf8');
324
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
325
+ if (!frontmatterMatch) return null;
326
+ const m = frontmatterMatch[1].match(/^version:\s*(\S+)\s*$/m);
327
+ return m ? m[1] : null;
328
+ }
329
+
330
+ // Version of the currently installed copy of a skill (agents dir, falling
331
+ // back to the legacy skills dir for pre-migration installs).
332
+ function getInstalledSkillVersion(name) {
333
+ const agentsDir = path.join(AGENTS_DIR, name);
334
+ if (fs.existsSync(agentsDir)) return getSkillVersion(agentsDir);
335
+ return getSkillVersion(path.join(SKILLS_DIR, name));
336
+ }
337
+
338
+ // Per-hook version from a `// @version x.y.z` comment near the top.
339
+ function getHookVersion(filePath) {
340
+ if (!fs.existsSync(filePath)) return null;
341
+ const m = fs.readFileSync(filePath, 'utf8').match(/^\/\/ @version\s+(\S+)\s*$/m);
342
+ return m ? m[1] : null;
343
+ }
344
+
345
+ // "name (1.0.0 → 1.1.0)" for updates, "name (v1.1.0)" for fresh installs.
346
+ function withVersion(name, oldV, newV) {
347
+ if (oldV && newV && oldV !== newV) return name + ' (' + oldV + ' → ' + newV + ')';
348
+ if (newV) return name + ' (v' + newV + ')';
349
+ return name;
350
+ }
351
+
352
+ // Copies a skill to ~/.agents/skills/<name> and creates/updates the symlink at ~/.claude/skills/<name>.
239
353
  // If a real (non-symlink) directory already exists at the skills path, it is removed first.
240
354
  function installSkillFiles(name) {
241
355
  const agentsSkillDir = path.join(AGENTS_DIR, name);
@@ -374,7 +488,10 @@ async function main() {
374
488
  changed = true;
375
489
  }
376
490
  }
377
- if (changed && !DRY_RUN) fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums0, null, 2));
491
+ if (changed && !DRY_RUN) {
492
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
493
+ fs.writeFileSync(CHECKSUMS_FILE, JSON.stringify(newChecksums0, null, 2));
494
+ }
378
495
  }
379
496
 
380
497
  // 1. Scan package skills
@@ -432,7 +549,7 @@ async function main() {
432
549
  installedDir = agentsSkillDir;
433
550
  } else {
434
551
  const legacyDir = path.join(SKILLS_DIR, name);
435
- if (fs.existsSync(legacyDir) && !isSymlink(legacyDir)) {
552
+ if (isRealDir(legacyDir)) {
436
553
  installedDir = legacyDir; // old-style install, will be migrated on next write
437
554
  }
438
555
  }
@@ -522,41 +639,51 @@ async function main() {
522
639
  fs.mkdirSync(AGENTS_DIR, { recursive: true });
523
640
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
524
641
  const newChecksums = Object.assign({}, storedChecksums);
525
- const updated = [];
526
- const mergeNeeded = [];
642
+ const updated = []; // display labels with version transitions
643
+ const mergeNeeded = []; // plain names (used in merge instructions)
644
+ const mergeNeededLabels = []; // display labels with version transitions
527
645
 
528
646
  for (const name of skillNames) {
647
+ const newV = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
529
648
  if (modifiedSkills.includes(name)) {
530
649
  // Backup local version, install new version
650
+ const oldV = getInstalledSkillVersion(name);
531
651
  backupSkill(name);
532
652
  installSkillFiles(name);
533
653
  newChecksums[name] = packageHashes[name];
534
654
  mergeNeeded.push(name);
655
+ mergeNeededLabels.push(withVersion(name, oldV, newV));
535
656
  continue;
536
657
  }
537
658
  if (!needsUpdate.includes(name) && fs.existsSync(path.join(AGENTS_DIR, name))) continue;
659
+ const oldV = getInstalledSkillVersion(name);
538
660
  installSkillFiles(name);
539
661
  newChecksums[name] = packageHashes[name];
540
- updated.push(name);
662
+ updated.push(withVersion(name, oldV, newV));
541
663
  }
542
664
 
543
665
  // Install hooks
544
666
  fs.mkdirSync(HOOKS_DIR, { recursive: true });
545
667
  const updatedHooks = [];
546
668
  const mergeNeededHooks = [];
669
+ const mergeNeededHookLabels = [];
547
670
 
548
671
  for (const filename of hookFilenames) {
672
+ const newV = getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename));
549
673
  if (modifiedHooks.includes(filename)) {
674
+ const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
550
675
  backupHook(filename);
551
676
  fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
552
677
  newChecksums['hook:' + filename] = packageHookHashes[filename];
553
678
  mergeNeededHooks.push(filename);
679
+ mergeNeededHookLabels.push(withVersion(filename, oldV, newV));
554
680
  continue;
555
681
  }
556
682
  if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) continue;
683
+ const oldV = getHookVersion(path.join(HOOKS_DIR, filename));
557
684
  fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
558
685
  newChecksums['hook:' + filename] = packageHookHashes[filename];
559
- updatedHooks.push(filename);
686
+ updatedHooks.push(withVersion(filename, oldV, newV));
560
687
  }
561
688
 
562
689
  setupRepoSearchNudgeHook();
@@ -578,7 +705,7 @@ async function main() {
578
705
  if (mergeNeeded.length > 0) {
579
706
  const backupPath = BACKUP_DIR.replace(HOME, '~');
580
707
  msgParts.push(
581
- 'estack: updated ' + mergeNeeded.join(', ') +
708
+ 'estack: updated ' + mergeNeededLabels.join(', ') +
582
709
  ' (local changes backed up to ' + backupPath + ')'
583
710
  );
584
711
  output.additionalContext =
@@ -595,7 +722,7 @@ async function main() {
595
722
  if (mergeNeededHooks.length > 0) {
596
723
  const backupPath = BACKUP_DIR.replace(HOME, '~');
597
724
  msgParts.push(
598
- 'estack: updated hooks ' + mergeNeededHooks.join(', ') +
725
+ 'estack: updated hooks ' + mergeNeededHookLabels.join(', ') +
599
726
  ' (local changes backed up to ' + backupPath + '/hooks/)'
600
727
  );
601
728
  const existingContext = output.additionalContext ? output.additionalContext + ' ' : '';
@@ -694,16 +821,18 @@ async function main() {
694
821
  if (DRY_RUN) console.log(' [dry run] Up to date (no change): ' + name);
695
822
  continue;
696
823
  }
697
- const skillsLegacyDir = path.join(SKILLS_DIR, name);
698
824
  const isUpdate = fs.existsSync(path.join(AGENTS_DIR, name)) ||
699
- (fs.existsSync(skillsLegacyDir) && !isSymlink(skillsLegacyDir));
825
+ isRealDir(path.join(SKILLS_DIR, name));
826
+ const label = withVersion(name,
827
+ isUpdate ? getInstalledSkillVersion(name) : null,
828
+ getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name)));
700
829
  if (!DRY_RUN) installSkillFiles(name);
701
830
  newChecksums[name] = packageHashes[name];
702
831
  installedCount++;
703
832
  if (DRY_RUN) {
704
- console.log(' [dry run] Would ' + (isUpdate ? 'update ' : 'install ') + name);
833
+ console.log(' [dry run] Would ' + (isUpdate ? 'update ' : 'install ') + label);
705
834
  } else {
706
- console.log(' Installed ' + name);
835
+ console.log(' Installed ' + label);
707
836
  }
708
837
  }
709
838
 
@@ -732,13 +861,16 @@ async function main() {
732
861
  continue;
733
862
  }
734
863
  const isHookUpdate = fs.existsSync(path.join(HOOKS_DIR, filename));
864
+ const hookLabel = withVersion(filename,
865
+ isHookUpdate ? getHookVersion(path.join(HOOKS_DIR, filename)) : null,
866
+ getHookVersion(path.join(PACKAGE_HOOKS_DIR, filename)));
735
867
  if (!DRY_RUN) fs.copyFileSync(path.join(PACKAGE_HOOKS_DIR, filename), path.join(HOOKS_DIR, filename));
736
868
  newChecksums['hook:' + filename] = packageHookHashes[filename];
737
869
  installedHookCount++;
738
870
  if (DRY_RUN) {
739
- console.log(' [dry run] Would ' + (isHookUpdate ? 'update hook ' : 'install hook ') + filename);
871
+ console.log(' [dry run] Would ' + (isHookUpdate ? 'update hook ' : 'install hook ') + hookLabel);
740
872
  } else {
741
- console.log(' Installed hook ' + filename);
873
+ console.log(' Installed hook ' + hookLabel);
742
874
  }
743
875
  }
744
876
 
@@ -753,13 +885,13 @@ async function main() {
753
885
  // 11. Summary output
754
886
  if (DRY_RUN) {
755
887
  console.log('\n[dry run] No files were changed. Run with --install to apply.\n');
756
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.agents/ (linked from ~/.claude/skills/)');
888
+ console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.agents/skills/ (linked from ~/.claude/skills/)');
757
889
  if (installedHookCount > 0) {
758
890
  console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' would be installed/updated in ~/.claude/hooks/');
759
891
  }
760
892
  } else {
761
893
  console.log('\nestack installed successfully!\n');
762
- console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.agents/ (symlinked from ~/.claude/skills/)');
894
+ console.log(' ' + installedCount + ' skill' + (installedCount !== 1 ? 's' : '') + ' installed to ~/.agents/skills/ (symlinked from ~/.claude/skills/)');
763
895
  if (installedHookCount > 0) {
764
896
  console.log(' ' + installedHookCount + ' hook' + (installedHookCount !== 1 ? 's' : '') + ' installed to ~/.claude/hooks/');
765
897
  }
@@ -769,7 +901,8 @@ async function main() {
769
901
 
770
902
  for (const name of skillNames) {
771
903
  const desc = getSkillDescription(path.join(PACKAGE_SKILLS_DIR, name));
772
- console.log(' /' + name + (desc ? ' — ' + desc : ''));
904
+ const ver = getSkillVersion(path.join(PACKAGE_SKILLS_DIR, name));
905
+ console.log(' /' + name + (ver ? ' v' + ver : '') + (desc ? ' — ' + desc : ''));
773
906
  }
774
907
 
775
908
  if (mergedSkills.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elliot-stack",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Elliot's skill stack for Claude Code — install via npx elliot-stack@latest",
5
5
  "bin": {
6
6
  "elliot-stack": "bin/install.cjs"