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.
- package/README.md +50 -12
- package/bin/gspec.js +372 -76
- package/commands/gspec.analyze.md +22 -8
- package/commands/gspec.audit.md +277 -0
- package/commands/gspec.feature.md +10 -0
- package/commands/gspec.implement.md +29 -15
- package/commands/gspec.migrate.md +29 -15
- package/commands/gspec.profile.md +55 -35
- package/commands/gspec.style.md +64 -12
- package/commands/gspec.tasks.md +150 -0
- package/dist/antigravity/gspec-analyze/SKILL.md +23 -9
- package/dist/antigravity/gspec-audit/SKILL.md +281 -0
- package/dist/antigravity/gspec-feature/SKILL.md +10 -0
- package/dist/antigravity/gspec-implement/SKILL.md +30 -16
- package/dist/antigravity/gspec-migrate/SKILL.md +29 -15
- package/dist/antigravity/gspec-profile/SKILL.md +55 -35
- package/dist/antigravity/gspec-style/SKILL.md +65 -13
- package/dist/antigravity/gspec-tasks/SKILL.md +154 -0
- package/dist/claude/gspec-analyze/SKILL.md +23 -9
- package/dist/claude/gspec-audit/SKILL.md +282 -0
- package/dist/claude/gspec-feature/SKILL.md +10 -0
- package/dist/claude/gspec-implement/SKILL.md +30 -16
- package/dist/claude/gspec-migrate/SKILL.md +29 -15
- package/dist/claude/gspec-profile/SKILL.md +55 -35
- package/dist/claude/gspec-style/SKILL.md +65 -13
- package/dist/claude/gspec-tasks/SKILL.md +155 -0
- package/dist/codex/gspec-analyze/SKILL.md +23 -9
- package/dist/codex/gspec-audit/SKILL.md +281 -0
- package/dist/codex/gspec-feature/SKILL.md +10 -0
- package/dist/codex/gspec-implement/SKILL.md +30 -16
- package/dist/codex/gspec-migrate/SKILL.md +29 -15
- package/dist/codex/gspec-profile/SKILL.md +55 -35
- package/dist/codex/gspec-style/SKILL.md +65 -13
- package/dist/codex/gspec-tasks/SKILL.md +154 -0
- package/dist/cursor/gspec-analyze.mdc +23 -9
- package/dist/cursor/gspec-audit.mdc +280 -0
- package/dist/cursor/gspec-feature.mdc +10 -0
- package/dist/cursor/gspec-implement.mdc +30 -16
- package/dist/cursor/gspec-migrate.mdc +29 -15
- package/dist/cursor/gspec-profile.mdc +55 -35
- package/dist/cursor/gspec-style.mdc +65 -13
- package/dist/cursor/gspec-tasks.mdc +153 -0
- package/dist/opencode/gspec-analyze/SKILL.md +23 -9
- package/dist/opencode/gspec-audit/SKILL.md +281 -0
- package/dist/opencode/gspec-feature/SKILL.md +10 -0
- package/dist/opencode/gspec-implement/SKILL.md +30 -16
- package/dist/opencode/gspec-migrate/SKILL.md +29 -15
- package/dist/opencode/gspec-profile/SKILL.md +55 -35
- package/dist/opencode/gspec-style/SKILL.md +65 -13
- package/dist/opencode/gspec-tasks/SKILL.md +154 -0
- package/package.json +1 -1
- 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
},
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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,
|
|
280
|
-
dest: join(gspecDir,
|
|
281
|
-
label: `gspec/${
|
|
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,
|
|
289
|
-
dest: join(gspecDir, 'features',
|
|
290
|
-
label: `gspec/features/${
|
|
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 (
|
|
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}
|
|
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}
|
|
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}
|
|
922
|
+
// Write to ~/.gspec/{type}/{name}{ext}
|
|
829
923
|
const destDir = join(GSPEC_HOME, selected.type);
|
|
830
|
-
const destPath = join(destDir, `${name}
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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',
|
|
1067
|
+
destPath = join(gspecDir, 'features', savedFilename);
|
|
974
1068
|
} else {
|
|
975
|
-
const destFile =
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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'
|
|
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();
|