gspec 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +50 -12
  2. package/bin/gspec.js +372 -76
  3. package/commands/gspec.analyze.md +22 -8
  4. package/commands/gspec.audit.md +277 -0
  5. package/commands/gspec.feature.md +10 -0
  6. package/commands/gspec.implement.md +29 -15
  7. package/commands/gspec.migrate.md +29 -15
  8. package/commands/gspec.profile.md +55 -35
  9. package/commands/gspec.style.md +64 -12
  10. package/commands/gspec.tasks.md +150 -0
  11. package/dist/antigravity/gspec-analyze/SKILL.md +23 -9
  12. package/dist/antigravity/gspec-audit/SKILL.md +281 -0
  13. package/dist/antigravity/gspec-feature/SKILL.md +10 -0
  14. package/dist/antigravity/gspec-implement/SKILL.md +30 -16
  15. package/dist/antigravity/gspec-migrate/SKILL.md +29 -15
  16. package/dist/antigravity/gspec-profile/SKILL.md +55 -35
  17. package/dist/antigravity/gspec-style/SKILL.md +65 -13
  18. package/dist/antigravity/gspec-tasks/SKILL.md +154 -0
  19. package/dist/claude/gspec-analyze/SKILL.md +23 -9
  20. package/dist/claude/gspec-audit/SKILL.md +282 -0
  21. package/dist/claude/gspec-feature/SKILL.md +10 -0
  22. package/dist/claude/gspec-implement/SKILL.md +30 -16
  23. package/dist/claude/gspec-migrate/SKILL.md +29 -15
  24. package/dist/claude/gspec-profile/SKILL.md +55 -35
  25. package/dist/claude/gspec-style/SKILL.md +65 -13
  26. package/dist/claude/gspec-tasks/SKILL.md +155 -0
  27. package/dist/codex/gspec-analyze/SKILL.md +23 -9
  28. package/dist/codex/gspec-audit/SKILL.md +281 -0
  29. package/dist/codex/gspec-feature/SKILL.md +10 -0
  30. package/dist/codex/gspec-implement/SKILL.md +30 -16
  31. package/dist/codex/gspec-migrate/SKILL.md +29 -15
  32. package/dist/codex/gspec-profile/SKILL.md +55 -35
  33. package/dist/codex/gspec-style/SKILL.md +65 -13
  34. package/dist/codex/gspec-tasks/SKILL.md +154 -0
  35. package/dist/cursor/gspec-analyze.mdc +23 -9
  36. package/dist/cursor/gspec-audit.mdc +280 -0
  37. package/dist/cursor/gspec-feature.mdc +10 -0
  38. package/dist/cursor/gspec-implement.mdc +30 -16
  39. package/dist/cursor/gspec-migrate.mdc +29 -15
  40. package/dist/cursor/gspec-profile.mdc +55 -35
  41. package/dist/cursor/gspec-style.mdc +65 -13
  42. package/dist/cursor/gspec-tasks.mdc +153 -0
  43. package/dist/opencode/gspec-analyze/SKILL.md +23 -9
  44. package/dist/opencode/gspec-audit/SKILL.md +281 -0
  45. package/dist/opencode/gspec-feature/SKILL.md +10 -0
  46. package/dist/opencode/gspec-implement/SKILL.md +30 -16
  47. package/dist/opencode/gspec-migrate/SKILL.md +29 -15
  48. package/dist/opencode/gspec-profile/SKILL.md +55 -35
  49. package/dist/opencode/gspec-style/SKILL.md +65 -13
  50. package/dist/opencode/gspec-tasks/SKILL.md +154 -0
  51. package/package.json +1 -1
  52. package/templates/spec-sync.md +8 -4
package/bin/gspec.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
- import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
4
+ import { readdir, readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
5
5
  import { join, dirname, basename } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createInterface } from 'node:readline';
9
9
  import chalk from 'chalk';
10
+ import { TARGETS as EMITTER_TARGETS } from '../scripts/emitters.js';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const DIST_DIR = join(__dirname, '..', 'dist');
@@ -28,38 +29,21 @@ const BANNER = `
28
29
  ${chalk.white('═════════════════════════════baller.software═══')}
29
30
  `;
30
31
 
31
- const TARGETS = {
32
- claude: {
33
- sourceDir: join(DIST_DIR, 'claude'),
34
- installDir: '.claude/skills',
35
- label: 'Claude Code',
36
- layout: 'directory',
37
- },
38
- cursor: {
39
- sourceDir: join(DIST_DIR, 'cursor'),
40
- installDir: '.cursor/commands',
41
- label: 'Cursor',
42
- layout: 'flat',
43
- },
44
- antigravity: {
45
- sourceDir: join(DIST_DIR, 'antigravity'),
46
- installDir: '.agent/skills',
47
- label: 'Antigravity',
48
- layout: 'directory',
49
- },
50
- codex: {
51
- sourceDir: join(DIST_DIR, 'codex'),
52
- installDir: '.agents/skills',
53
- label: 'Codex',
54
- layout: 'directory',
55
- },
56
- opencode: {
57
- sourceDir: join(DIST_DIR, 'opencode'),
58
- installDir: '.opencode/skills',
59
- label: 'Open Code',
60
- layout: 'directory',
61
- },
62
- };
32
+ // Derive install-side TARGETS from the shared emitter config so we have one source of truth.
33
+ // `sourceDir` is computed from the shared `distSubdir`; `emit` is reused for installing user extensions.
34
+ const TARGETS = Object.fromEntries(
35
+ Object.entries(EMITTER_TARGETS).map(([key, t]) => [key, {
36
+ ...t,
37
+ sourceDir: join(DIST_DIR, t.distSubdir),
38
+ }]),
39
+ );
40
+
41
+ // Names emitted by core gspec; user extensions cannot collide with these.
42
+ const BUILTIN_SKILL_NAMES = new Set([
43
+ 'gspec-profile', 'gspec-feature', 'gspec-tasks', 'gspec-style',
44
+ 'gspec-stack', 'gspec-practices', 'gspec-architect', 'gspec-analyze',
45
+ 'gspec-audit', 'gspec-research', 'gspec-implement', 'gspec-migrate',
46
+ ]);
63
47
 
64
48
  const TARGET_CHOICES = [
65
49
  { key: '1', name: 'claude', label: 'Claude Code' },
@@ -255,11 +239,13 @@ async function seedFromSavedSpecs(cwd) {
255
239
  const gspecDir = join(cwd, 'gspec');
256
240
  const filesToWrite = [];
257
241
 
242
+ // `dest` is null for types whose destination filename depends on the saved file's extension
243
+ // (currently `styles`, which may be .md or .html).
258
244
  const CATEGORY_ORDER = [
259
245
  { type: 'profiles', label: 'Select a profile', dest: 'profile.md', mode: 'single' },
260
246
  { type: 'practices', label: 'Select practices', dest: 'practices.md', mode: 'single' },
261
247
  { type: 'stacks', label: 'Select a stack', dest: 'stack.md', mode: 'single' },
262
- { type: 'styles', label: 'Select a style', dest: 'style.md', mode: 'single' },
248
+ { type: 'styles', label: 'Select a style', dest: null, mode: 'single' },
263
249
  { type: 'features', label: 'Select features (optional)', dest: null, mode: 'multi' },
264
250
  ];
265
251
 
@@ -275,19 +261,24 @@ async function seedFromSavedSpecs(cwd) {
275
261
  : await promptSelect(cat.label, [...specs, NONE_OPTION]);
276
262
 
277
263
  if (selected !== '_none') {
264
+ const savedFilename = await resolveSavedSpecFilename(cat.type, selected);
265
+ if (!savedFilename) continue;
266
+ const destFilename = cat.dest || destFilenameForRestoredSpec(cat.type, savedFilename);
278
267
  filesToWrite.push({
279
- src: join(gspecHome, cat.type, `${selected}.md`),
280
- dest: join(gspecDir, cat.dest),
281
- label: `gspec/${cat.dest}`,
268
+ src: join(gspecHome, cat.type, savedFilename),
269
+ dest: join(gspecDir, destFilename),
270
+ label: `gspec/${destFilename}`,
282
271
  });
283
272
  }
284
273
  } else {
285
274
  let selectedSlugs = await promptMultiSelect(cat.label, specs);
286
275
  for (const slug of selectedSlugs) {
276
+ const savedFilename = await resolveSavedSpecFilename(cat.type, slug);
277
+ if (!savedFilename) continue;
287
278
  filesToWrite.push({
288
- src: join(gspecHome, cat.type, `${slug}.md`),
289
- dest: join(gspecDir, 'features', `${slug}.md`),
290
- label: `gspec/features/${slug}.md`,
279
+ src: join(gspecHome, cat.type, savedFilename),
280
+ dest: join(gspecDir, 'features', savedFilename),
281
+ label: `gspec/features/${savedFilename}`,
291
282
  });
292
283
  }
293
284
  }
@@ -561,6 +552,11 @@ const MIGRATE_COMMANDS = {
561
552
  };
562
553
 
563
554
  function parseSpecVersion(content) {
555
+ // HTML spec files store the version as a first-line comment:
556
+ // <!-- spec-version: v1 -->
557
+ const htmlMatch = content.match(/^\s*<!--\s*spec-version:\s*([^\s-][^-]*?)\s*-->/);
558
+ if (htmlMatch) return htmlMatch[1].trim();
559
+
564
560
  const match = content.match(/^---\n([\s\S]*?)\n---/);
565
561
  if (!match) return null;
566
562
  const newMatch = match[1].match(/^spec-version:\s*(.+)$/m);
@@ -578,6 +574,11 @@ async function collectGspecFiles(gspecDir) {
578
574
  if (entry.endsWith('.md') && entry.toLowerCase() !== 'readme.md') {
579
575
  files.push({ path: join(gspecDir, entry), label: `gspec/${entry}` });
580
576
  }
577
+ // Pick up style.html (the HTML-format style guide) alongside Markdown specs.
578
+ // Other .html files under gspec/ are not gspec-owned and are skipped.
579
+ if (entry === 'style.html') {
580
+ files.push({ path: join(gspecDir, entry), label: `gspec/${entry}` });
581
+ }
581
582
  }
582
583
 
583
584
  for (const subdir of ['features', 'epics']) {
@@ -648,19 +649,82 @@ const GSPEC_TYPE_MAP = {
648
649
  'profile.md': 'profiles',
649
650
  'stack.md': 'stacks',
650
651
  'style.md': 'styles',
652
+ 'style.html': 'styles',
651
653
  'practices.md': 'practices',
652
654
  };
653
655
 
654
656
  // Reverse: restore type folder → gspec/ destination filename
657
+ // The `styles` entry is a function because the destination depends on the saved file's extension.
655
658
  const RESTORE_DEST_MAP = {
656
659
  profiles: 'profile.md',
657
660
  stacks: 'stack.md',
658
- styles: 'style.md',
661
+ styles: 'style.md', // default when the saved extension is .md
659
662
  practices: 'practices.md',
660
663
  features: null, // features keep their own filename
661
664
  };
662
665
 
666
+ // Given a save-type folder and a saved slug, resolve the actual filename in ~/.gspec/<type>/.
667
+ // Styles can be stored as .md or .html; all others are .md.
668
+ async function resolveSavedSpecFilename(type, slug) {
669
+ const dir = join(GSPEC_HOME, type);
670
+ const candidates = type === 'styles'
671
+ ? [`${slug}.md`, `${slug}.html`]
672
+ : [`${slug}.md`];
673
+ for (const candidate of candidates) {
674
+ try {
675
+ await stat(join(dir, candidate));
676
+ return candidate;
677
+ } catch (e) {
678
+ if (e.code !== 'ENOENT') throw e;
679
+ }
680
+ }
681
+ return null;
682
+ }
683
+
684
+ // Destination filename in a project's gspec/ directory for a restored saved spec.
685
+ // For styles, preserve the saved file's extension so a .html style guide restores as style.html.
686
+ function destFilenameForRestoredSpec(type, savedFilename) {
687
+ if (type === 'features') return savedFilename;
688
+ if (type === 'styles') {
689
+ return savedFilename.endsWith('.html') ? 'style.html' : 'style.md';
690
+ }
691
+ return RESTORE_DEST_MAP[type];
692
+ }
693
+
694
+ function isHtmlSpec(content) {
695
+ const head = content.slice(0, 500).trimStart().toLowerCase();
696
+ if (head.startsWith('<!doctype') || head.startsWith('<html')) return true;
697
+ // Leading HTML comments before <!DOCTYPE> (where we store HTML spec metadata)
698
+ if (head.startsWith('<!--')) {
699
+ // Peek further to see if a <!DOCTYPE> / <html> follows the comments
700
+ const scan = content.slice(0, 2000).toLowerCase();
701
+ return /<!doctype|<html/.test(scan);
702
+ }
703
+ return false;
704
+ }
705
+
706
+ function parseHtmlMetadata(content) {
707
+ // Consume consecutive `<!-- key: value -->` comments at the top of the file,
708
+ // stopping at the first non-comment, non-blank line.
709
+ const fields = {};
710
+ const lines = content.split('\n');
711
+ let bodyStart = 0;
712
+ for (let i = 0; i < lines.length; i++) {
713
+ const trimmed = lines[i].trim();
714
+ if (trimmed === '') {
715
+ bodyStart = i + 1;
716
+ continue;
717
+ }
718
+ const match = trimmed.match(/^<!--\s*([\w-]+):\s*(.+?)\s*-->$/);
719
+ if (!match) break;
720
+ fields[match[1]] = match[2];
721
+ bodyStart = i + 1;
722
+ }
723
+ return { fields, body: lines.slice(bodyStart).join('\n') };
724
+ }
725
+
663
726
  function parseFrontmatter(content) {
727
+ if (isHtmlSpec(content)) return parseHtmlMetadata(content);
664
728
  const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
665
729
  if (!match) return { fields: {}, body: content };
666
730
  const fields = {};
@@ -673,7 +737,34 @@ function parseFrontmatter(content) {
673
737
  return { fields, body: content.slice(match[0].length) };
674
738
  }
675
739
 
740
+ function setHtmlMetadataField(content, key, value) {
741
+ const lines = content.split('\n');
742
+ let lastCommentIndex = -1;
743
+ for (let i = 0; i < lines.length; i++) {
744
+ const trimmed = lines[i].trim();
745
+ if (trimmed === '') continue;
746
+ if (trimmed.startsWith('<!--') && trimmed.endsWith('-->')) {
747
+ lastCommentIndex = i;
748
+ const m = trimmed.match(/^<!--\s*([\w-]+):\s*(.+?)\s*-->$/);
749
+ if (m && m[1] === key) {
750
+ lines[i] = `<!-- ${key}: ${value} -->`;
751
+ return lines.join('\n');
752
+ }
753
+ continue;
754
+ }
755
+ break;
756
+ }
757
+ const newComment = `<!-- ${key}: ${value} -->`;
758
+ if (lastCommentIndex >= 0) {
759
+ lines.splice(lastCommentIndex + 1, 0, newComment);
760
+ } else {
761
+ lines.unshift(newComment);
762
+ }
763
+ return lines.join('\n');
764
+ }
765
+
676
766
  function setFrontmatterField(content, key, value) {
767
+ if (isHtmlSpec(content)) return setHtmlMetadataField(content, key, value);
677
768
  const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
678
769
  if (!match) {
679
770
  // No frontmatter — create one
@@ -711,10 +802,11 @@ async function collectSavableFiles(cwd) {
711
802
  throw e;
712
803
  }
713
804
 
714
- // Top-level spec files
805
+ // Top-level spec files. Accept the Markdown specs plus the HTML style guide.
715
806
  const topEntries = await readdir(gspecDir);
716
807
  for (const entry of topEntries) {
717
- if (!entry.endsWith('.md') || entry.toLowerCase() === 'readme.md') continue;
808
+ if (entry.toLowerCase() === 'readme.md') continue;
809
+ if (!entry.endsWith('.md') && entry !== 'style.html') continue;
718
810
  const type = GSPEC_TYPE_MAP[entry];
719
811
  if (!type) continue;
720
812
  files.push({
@@ -769,6 +861,8 @@ async function saveSpec(cwd) {
769
861
  }
770
862
 
771
863
  const selected = files[num - 1];
864
+ // Preserve the source file's extension when saving (.md for most specs, .html for style.html).
865
+ const ext = selected.path.endsWith('.html') ? '.html' : '.md';
772
866
 
773
867
  // Read source content and look for an existing name in frontmatter
774
868
  let content = await readFile(selected.path, 'utf-8');
@@ -779,7 +873,7 @@ async function saveSpec(cwd) {
779
873
  let overwriteConfirmed = false;
780
874
 
781
875
  if (existingName) {
782
- const existingPath = join(GSPEC_HOME, selected.type, `${existingName}.md`);
876
+ const existingPath = join(GSPEC_HOME, selected.type, `${existingName}${ext}`);
783
877
  let savedExists = false;
784
878
  try {
785
879
  await stat(existingPath);
@@ -790,7 +884,7 @@ async function saveSpec(cwd) {
790
884
 
791
885
  if (savedExists) {
792
886
  const overwrite = await promptConfirmYes(
793
- chalk.bold(`\n Overwrite existing ~/.gspec/${selected.type}/${existingName}.md? [Y/n]: `)
887
+ chalk.bold(`\n Overwrite existing ~/.gspec/${selected.type}/${existingName}${ext}? [Y/n]: `)
794
888
  );
795
889
  if (overwrite) {
796
890
  name = existingName;
@@ -825,16 +919,16 @@ async function saveSpec(cwd) {
825
919
  }
826
920
  }
827
921
 
828
- // Write to ~/.gspec/{type}/{name}.md
922
+ // Write to ~/.gspec/{type}/{name}{ext}
829
923
  const destDir = join(GSPEC_HOME, selected.type);
830
- const destPath = join(destDir, `${name}.md`);
924
+ const destPath = join(destDir, `${name}${ext}`);
831
925
  await mkdir(destDir, { recursive: true });
832
926
 
833
927
  // Check for conflict unless overwrite was already confirmed above
834
928
  if (!overwriteConfirmed) {
835
929
  try {
836
930
  await stat(destPath);
837
- const overwrite = await promptConfirm(chalk.yellow(`\n ${selected.type}/${name} already exists. Overwrite? [y/N]: `));
931
+ const overwrite = await promptConfirm(chalk.yellow(`\n ${selected.type}/${name}${ext} already exists. Overwrite? [y/N]: `));
838
932
  if (!overwrite) {
839
933
  console.log(chalk.dim('\n Save cancelled.\n'));
840
934
  return;
@@ -848,7 +942,13 @@ async function saveSpec(cwd) {
848
942
  content = content.replace(/- \[x\]/g, '- [ ]');
849
943
 
850
944
  await writeFile(destPath, content, 'utf-8');
851
- console.log(chalk.green(`\n ✓ Saved to ~/.gspec/${selected.type}/${name}.md\n`));
945
+ console.log(chalk.green(`\n ✓ Saved to ~/.gspec/${selected.type}/${name}${ext}\n`));
946
+ }
947
+
948
+ function isSavedSpecFile(type, filename) {
949
+ if (filename.endsWith('.md')) return true;
950
+ if (type === 'styles' && filename.endsWith('.html')) return true;
951
+ return false;
852
952
  }
853
953
 
854
954
  async function listSavedTypes() {
@@ -860,8 +960,7 @@ async function listSavedTypes() {
860
960
  const info = await stat(join(GSPEC_HOME, entry));
861
961
  if (info.isDirectory()) {
862
962
  const files = await readdir(join(GSPEC_HOME, entry));
863
- const mdFiles = files.filter((f) => f.endsWith('.md'));
864
- if (mdFiles.length > 0) types.push(entry);
963
+ if (files.some((f) => isSavedSpecFile(entry, f))) types.push(entry);
865
964
  }
866
965
  } catch { /* skip */ }
867
966
  }
@@ -877,11 +976,11 @@ async function listSavedSpecs(type) {
877
976
  const entries = await readdir(dir);
878
977
  const specs = [];
879
978
  for (const entry of entries) {
880
- if (!entry.endsWith('.md')) continue;
979
+ if (!isSavedSpecFile(type, entry)) continue;
881
980
  const content = await readFile(join(dir, entry), 'utf-8');
882
981
  const { fields } = parseFrontmatter(content);
883
982
  specs.push({
884
- slug: entry.replace(/\.md$/, ''),
983
+ slug: entry.replace(/\.(md|html)$/, ''),
885
984
  description: fields.description || '',
886
985
  });
887
986
  }
@@ -954,25 +1053,20 @@ async function restoreSpec(specPath, cwd) {
954
1053
  }
955
1054
 
956
1055
  async function restoreFile(type, name, cwd) {
957
- const srcPath = join(GSPEC_HOME, type, `${name}.md`);
958
-
959
- try {
960
- await stat(srcPath);
961
- } catch (e) {
962
- if (e.code === 'ENOENT') {
963
- console.error(chalk.red(`\n Not found: ~/.gspec/${type}/${name}.md\n`));
964
- process.exit(1);
965
- }
966
- throw e;
1056
+ const savedFilename = await resolveSavedSpecFilename(type, name);
1057
+ if (!savedFilename) {
1058
+ console.error(chalk.red(`\n Not found: ~/.gspec/${type}/${name}.md\n`));
1059
+ process.exit(1);
967
1060
  }
1061
+ const srcPath = join(GSPEC_HOME, type, savedFilename);
968
1062
 
969
1063
  const gspecDir = join(cwd, 'gspec');
970
1064
  let destPath;
971
1065
 
972
1066
  if (type === 'features') {
973
- destPath = join(gspecDir, 'features', `${name}.md`);
1067
+ destPath = join(gspecDir, 'features', savedFilename);
974
1068
  } else {
975
- const destFile = RESTORE_DEST_MAP[type];
1069
+ const destFile = destFilenameForRestoredSpec(type, savedFilename);
976
1070
  if (!destFile) {
977
1071
  console.error(chalk.red(`\n Unknown spec type: ${type}\n`));
978
1072
  process.exit(1);
@@ -1201,10 +1295,18 @@ async function restorePlaybook(name, cwd) {
1201
1295
  restorations.push({ type: 'features', slug: f });
1202
1296
  }
1203
1297
 
1298
+ // Resolve each restoration to its actual saved filename (styles may be .md or .html)
1299
+ for (const r of restorations) {
1300
+ r.savedFilename = await resolveSavedSpecFilename(r.type, r.slug);
1301
+ }
1302
+
1204
1303
  // Check for existing files
1205
1304
  const existing = [];
1206
1305
  for (const r of restorations) {
1207
- const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
1306
+ if (!r.savedFilename) continue;
1307
+ const destFile = r.type === 'features'
1308
+ ? join('features', r.savedFilename)
1309
+ : destFilenameForRestoredSpec(r.type, r.savedFilename);
1208
1310
  const destPath = join(gspecDir, destFile);
1209
1311
  try {
1210
1312
  await stat(destPath);
@@ -1230,18 +1332,15 @@ async function restorePlaybook(name, cwd) {
1230
1332
  // Restore all specs
1231
1333
  const outdated = [];
1232
1334
  for (const r of restorations) {
1233
- const srcFile = join(GSPEC_HOME, r.type, `${r.slug}.md`);
1234
- try {
1235
- await stat(srcFile);
1236
- } catch (e) {
1237
- if (e.code === 'ENOENT') {
1238
- console.log(` ${chalk.yellow('!')} Skipped ${r.type}/${r.slug} — not found in ~/.gspec/`);
1239
- continue;
1240
- }
1241
- throw e;
1335
+ if (!r.savedFilename) {
1336
+ console.log(` ${chalk.yellow('!')} Skipped ${r.type}/${r.slug} — not found in ~/.gspec/`);
1337
+ continue;
1242
1338
  }
1339
+ const srcFile = join(GSPEC_HOME, r.type, r.savedFilename);
1243
1340
 
1244
- const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
1341
+ const destFile = r.type === 'features'
1342
+ ? join('features', r.savedFilename)
1343
+ : destFilenameForRestoredSpec(r.type, r.savedFilename);
1245
1344
  const destPath = join(gspecDir, destFile);
1246
1345
  await mkdir(dirname(destPath), { recursive: true });
1247
1346
  const specContent = await readFile(srcFile, 'utf-8');
@@ -1282,6 +1381,8 @@ program
1282
1381
 
1283
1382
  await install(targetName, process.cwd());
1284
1383
 
1384
+ await installExtensions(targetName, process.cwd());
1385
+
1285
1386
  await seedFromSavedSpecs(process.cwd());
1286
1387
 
1287
1388
  await installSpecSync(targetName, process.cwd());
@@ -1309,6 +1410,176 @@ program
1309
1410
  }
1310
1411
  });
1311
1412
 
1413
+ // --- Extensions ---
1414
+
1415
+ const EXTENSIONS_DIR = join(GSPEC_HOME, 'extensions');
1416
+ const EXTENSION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
1417
+
1418
+ async function loadExtensions() {
1419
+ let entries;
1420
+ try {
1421
+ entries = await readdir(EXTENSIONS_DIR);
1422
+ } catch (e) {
1423
+ if (e.code === 'ENOENT') return [];
1424
+ throw e;
1425
+ }
1426
+
1427
+ const files = entries.filter((f) => f.endsWith('.md'));
1428
+ const loaded = [];
1429
+ for (const file of files) {
1430
+ const path = join(EXTENSIONS_DIR, file);
1431
+ const content = await readFile(path, 'utf-8');
1432
+ const { fields, body } = parseFrontmatter(content);
1433
+ loaded.push({ file, path, fields, body, content });
1434
+ }
1435
+ return loaded;
1436
+ }
1437
+
1438
+ function validateExtension(ext) {
1439
+ const errors = [];
1440
+ if (!ext.fields.name) errors.push("missing 'name' frontmatter");
1441
+ if (!ext.fields.description) errors.push("missing 'description' frontmatter");
1442
+ if (ext.fields.name && !EXTENSION_NAME_RE.test(ext.fields.name)) {
1443
+ errors.push(`invalid name "${ext.fields.name}" (must match /^[a-z0-9][a-z0-9-]*$/)`);
1444
+ }
1445
+ if (ext.fields.name && BUILTIN_SKILL_NAMES.has(ext.fields.name)) {
1446
+ errors.push(`name "${ext.fields.name}" collides with a built-in gspec skill`);
1447
+ }
1448
+ return errors;
1449
+ }
1450
+
1451
+ async function installExtensions(targetName, cwd) {
1452
+ const extensions = await loadExtensions();
1453
+ if (extensions.length === 0) return;
1454
+
1455
+ const target = TARGETS[targetName];
1456
+ const valid = [];
1457
+ for (const ext of extensions) {
1458
+ const errors = validateExtension(ext);
1459
+ if (errors.length > 0) {
1460
+ console.warn(chalk.yellow(` ! Skipping extension ${ext.file}: ${errors.join('; ')}`));
1461
+ continue;
1462
+ }
1463
+ valid.push(ext);
1464
+ }
1465
+
1466
+ // Resolve duplicates by name (last write wins, with a warning)
1467
+ const byName = new Map();
1468
+ for (const ext of valid) {
1469
+ if (byName.has(ext.fields.name)) {
1470
+ console.warn(chalk.yellow(
1471
+ ` ! Extension name "${ext.fields.name}" defined in two files; ${ext.file} overrides ${byName.get(ext.fields.name).file}`
1472
+ ));
1473
+ }
1474
+ byName.set(ext.fields.name, ext);
1475
+ }
1476
+ const finalSet = Array.from(byName.values());
1477
+ if (finalSet.length === 0) return;
1478
+
1479
+ console.log(chalk.bold(`\nInstalling ${finalSet.length} user extension${finalSet.length === 1 ? '' : 's'} from ~/.gspec/extensions/...\n`));
1480
+ const installPath = join(cwd, target.installDir);
1481
+ for (const ext of finalSet) {
1482
+ const meta = { name: ext.fields.name, description: ext.fields.description };
1483
+ await target.emit(installPath, ext.body, meta);
1484
+ console.log(` ${chalk.green('+')} ${ext.fields.name} ${chalk.dim('(extension)')}`);
1485
+ }
1486
+ }
1487
+
1488
+ async function extensionList() {
1489
+ console.log(BANNER);
1490
+ const extensions = await loadExtensions();
1491
+ if (extensions.length === 0) {
1492
+ console.log(chalk.dim('\n No extensions installed in ~/.gspec/extensions/.\n'));
1493
+ console.log(chalk.dim(' Use "gspec extension save <path>" to install one.\n'));
1494
+ return;
1495
+ }
1496
+
1497
+ console.log(chalk.bold(`\n ${extensions.length} extension${extensions.length === 1 ? '' : 's'} in ~/.gspec/extensions/:\n`));
1498
+ for (const ext of extensions) {
1499
+ const errors = validateExtension(ext);
1500
+ const name = ext.fields.name || chalk.dim('(no name)');
1501
+ const desc = ext.fields.description ? chalk.dim(` — ${ext.fields.description}`) : '';
1502
+ if (errors.length > 0) {
1503
+ console.log(` ${chalk.yellow('!')} ${ext.file} → ${name}${desc}`);
1504
+ console.log(` ${chalk.yellow(errors.join('; '))}`);
1505
+ } else {
1506
+ console.log(` ${chalk.green('•')} ${name}${desc}`);
1507
+ console.log(` ${chalk.dim(ext.file)}`);
1508
+ }
1509
+ }
1510
+ console.log();
1511
+ }
1512
+
1513
+ async function extensionSave(srcPath) {
1514
+ console.log(BANNER);
1515
+
1516
+ if (!srcPath) {
1517
+ console.error(chalk.red('\n Usage: gspec extension save <path-to-extension.md>\n'));
1518
+ process.exit(1);
1519
+ }
1520
+
1521
+ let content;
1522
+ try {
1523
+ content = await readFile(srcPath, 'utf-8');
1524
+ } catch (e) {
1525
+ if (e.code === 'ENOENT') {
1526
+ console.error(chalk.red(`\n File not found: ${srcPath}\n`));
1527
+ process.exit(1);
1528
+ }
1529
+ throw e;
1530
+ }
1531
+
1532
+ const { fields } = parseFrontmatter(content);
1533
+ const ext = { file: basename(srcPath), fields };
1534
+ const errors = validateExtension(ext);
1535
+ if (errors.length > 0) {
1536
+ console.error(chalk.red(`\n Cannot save extension: ${errors.join('; ')}\n`));
1537
+ process.exit(1);
1538
+ }
1539
+
1540
+ await mkdir(EXTENSIONS_DIR, { recursive: true });
1541
+ const destPath = join(EXTENSIONS_DIR, `${fields.name}.md`);
1542
+
1543
+ try {
1544
+ await stat(destPath);
1545
+ const overwrite = await promptConfirm(chalk.yellow(`\n Extension "${fields.name}" already exists. Overwrite? [y/N]: `));
1546
+ if (!overwrite) {
1547
+ console.log(chalk.dim('\n Cancelled.\n'));
1548
+ return;
1549
+ }
1550
+ } catch (e) {
1551
+ if (e.code !== 'ENOENT') throw e;
1552
+ }
1553
+
1554
+ await writeFile(destPath, content, 'utf-8');
1555
+ console.log(chalk.green(`\n ✓ Saved extension to ~/.gspec/extensions/${fields.name}.md\n`));
1556
+ console.log(chalk.dim(` It will be installed alongside core skills the next time you run "gspec" in a project.\n`));
1557
+ }
1558
+
1559
+ async function extensionRemove(name) {
1560
+ console.log(BANNER);
1561
+
1562
+ if (!name) {
1563
+ console.error(chalk.red('\n Usage: gspec extension remove <name>\n'));
1564
+ process.exit(1);
1565
+ }
1566
+
1567
+ const path = join(EXTENSIONS_DIR, `${name}.md`);
1568
+ try {
1569
+ await stat(path);
1570
+ } catch (e) {
1571
+ if (e.code === 'ENOENT') {
1572
+ console.error(chalk.red(`\n Extension not found: ~/.gspec/extensions/${name}.md\n`));
1573
+ process.exit(1);
1574
+ }
1575
+ throw e;
1576
+ }
1577
+
1578
+ await unlink(path);
1579
+ console.log(chalk.green(`\n ✓ Removed ~/.gspec/extensions/${name}.md\n`));
1580
+ console.log(chalk.dim(` Already-installed copies in projects (.claude/skills/, .cursor/commands/, etc.) are left in place — delete them manually if desired.\n`));
1581
+ }
1582
+
1312
1583
  program
1313
1584
  .command('save')
1314
1585
  .description('Save a gspec spec to ~/.gspec for reuse across projects')
@@ -1331,4 +1602,29 @@ program
1331
1602
  await createPlaybook();
1332
1603
  });
1333
1604
 
1605
+ const extensionCmd = program
1606
+ .command('extension')
1607
+ .description('Manage user-authored gspec extension skills in ~/.gspec/extensions/');
1608
+
1609
+ extensionCmd
1610
+ .command('list')
1611
+ .description('List installed extensions')
1612
+ .action(async () => {
1613
+ await extensionList();
1614
+ });
1615
+
1616
+ extensionCmd
1617
+ .command('save <path>')
1618
+ .description('Save a local .md skill file as a user extension in ~/.gspec/extensions/')
1619
+ .action(async (path) => {
1620
+ await extensionSave(path);
1621
+ });
1622
+
1623
+ extensionCmd
1624
+ .command('remove <name>')
1625
+ .description('Remove a user extension from ~/.gspec/extensions/ (does not uninstall already-emitted copies)')
1626
+ .action(async (name) => {
1627
+ await extensionRemove(name);
1628
+ });
1629
+
1334
1630
  program.parse();