gspec 1.11.0 → 1.13.1

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 (78) hide show
  1. package/README.md +43 -34
  2. package/bin/gspec.js +765 -159
  3. package/commands/gspec.analyze.md +1 -1
  4. package/commands/gspec.architect.md +2 -2
  5. package/commands/gspec.feature.md +2 -2
  6. package/commands/gspec.implement.md +1 -1
  7. package/commands/gspec.migrate.md +13 -10
  8. package/commands/gspec.practices.md +2 -2
  9. package/commands/gspec.profile.md +2 -2
  10. package/commands/gspec.research.md +4 -4
  11. package/commands/gspec.stack.md +2 -2
  12. package/commands/gspec.style.md +2 -2
  13. package/dist/antigravity/gspec-analyze/SKILL.md +1 -1
  14. package/dist/antigravity/gspec-architect/SKILL.md +2 -2
  15. package/dist/antigravity/gspec-feature/SKILL.md +2 -2
  16. package/dist/antigravity/gspec-implement/SKILL.md +1 -1
  17. package/dist/antigravity/gspec-migrate/SKILL.md +13 -10
  18. package/dist/antigravity/gspec-practices/SKILL.md +2 -2
  19. package/dist/antigravity/gspec-profile/SKILL.md +2 -2
  20. package/dist/antigravity/gspec-research/SKILL.md +4 -4
  21. package/dist/antigravity/gspec-stack/SKILL.md +2 -2
  22. package/dist/antigravity/gspec-style/SKILL.md +2 -2
  23. package/dist/claude/gspec-analyze/SKILL.md +1 -1
  24. package/dist/claude/gspec-architect/SKILL.md +2 -2
  25. package/dist/claude/gspec-feature/SKILL.md +2 -2
  26. package/dist/claude/gspec-implement/SKILL.md +1 -1
  27. package/dist/claude/gspec-migrate/SKILL.md +13 -10
  28. package/dist/claude/gspec-practices/SKILL.md +2 -2
  29. package/dist/claude/gspec-profile/SKILL.md +2 -2
  30. package/dist/claude/gspec-research/SKILL.md +4 -4
  31. package/dist/claude/gspec-stack/SKILL.md +2 -2
  32. package/dist/claude/gspec-style/SKILL.md +2 -2
  33. package/dist/codex/gspec-analyze/SKILL.md +1 -1
  34. package/dist/codex/gspec-architect/SKILL.md +2 -2
  35. package/dist/codex/gspec-feature/SKILL.md +2 -2
  36. package/dist/codex/gspec-implement/SKILL.md +1 -1
  37. package/dist/codex/gspec-migrate/SKILL.md +13 -10
  38. package/dist/codex/gspec-practices/SKILL.md +2 -2
  39. package/dist/codex/gspec-profile/SKILL.md +2 -2
  40. package/dist/codex/gspec-research/SKILL.md +4 -4
  41. package/dist/codex/gspec-stack/SKILL.md +2 -2
  42. package/dist/codex/gspec-style/SKILL.md +2 -2
  43. package/dist/cursor/gspec-analyze.mdc +1 -1
  44. package/dist/cursor/gspec-architect.mdc +2 -2
  45. package/dist/cursor/gspec-feature.mdc +2 -2
  46. package/dist/cursor/gspec-implement.mdc +1 -1
  47. package/dist/cursor/gspec-migrate.mdc +13 -10
  48. package/dist/cursor/gspec-practices.mdc +2 -2
  49. package/dist/cursor/gspec-profile.mdc +2 -2
  50. package/dist/cursor/gspec-research.mdc +4 -4
  51. package/dist/cursor/gspec-stack.mdc +2 -2
  52. package/dist/cursor/gspec-style.mdc +2 -2
  53. package/dist/opencode/gspec-analyze/SKILL.md +1 -1
  54. package/dist/opencode/gspec-architect/SKILL.md +2 -2
  55. package/dist/opencode/gspec-feature/SKILL.md +2 -2
  56. package/dist/opencode/gspec-implement/SKILL.md +1 -1
  57. package/dist/opencode/gspec-migrate/SKILL.md +13 -10
  58. package/dist/opencode/gspec-practices/SKILL.md +2 -2
  59. package/dist/opencode/gspec-profile/SKILL.md +2 -2
  60. package/dist/opencode/gspec-research/SKILL.md +4 -4
  61. package/dist/opencode/gspec-stack/SKILL.md +2 -2
  62. package/dist/opencode/gspec-style/SKILL.md +2 -2
  63. package/package.json +1 -2
  64. package/templates/spec-sync.md +1 -1
  65. package/starters/features/about-page.md +0 -98
  66. package/starters/features/contact-form.md +0 -147
  67. package/starters/features/contact-page.md +0 -103
  68. package/starters/features/home-page.md +0 -103
  69. package/starters/features/responsive-navbar.md +0 -113
  70. package/starters/features/services-page.md +0 -103
  71. package/starters/features/site-footer.md +0 -121
  72. package/starters/features/theme-switcher.md +0 -124
  73. package/starters/practices/tdd-pipeline-first.md +0 -192
  74. package/starters/stacks/astro-tailwind-github-pages.md +0 -283
  75. package/starters/stacks/nextjs-supabase-vercel.md +0 -319
  76. package/starters/stacks/nextjs-vercel-typescript.md +0 -264
  77. package/starters/styles/clean-professional.md +0 -316
  78. package/starters/styles/dark-minimal-developer.md +0 -442
package/bin/gspec.js CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  import { program } from 'commander';
4
4
  import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
5
- import { join, dirname } from 'node:path';
5
+ import { join, dirname, basename } from 'node:path';
6
+ import { homedir } from 'node:os';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { createInterface } from 'node:readline';
8
9
  import chalk from 'chalk';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const DIST_DIR = join(__dirname, '..', 'dist');
12
- const STARTERS_DIR = join(__dirname, '..', 'starters');
13
13
  const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf-8'));
14
+ const SPEC_VERSION = 'v1';
14
15
 
15
16
  const BANNER = `
16
17
  ${chalk.cyan('╔══════════════════════════════════════════════╗')}
@@ -117,67 +118,6 @@ function promptConfirmNo(message) {
117
118
  });
118
119
  }
119
120
 
120
- // --- Feature dependency map ---
121
- // Maps feature slugs to their required feature dependencies (other feature slugs).
122
- // This is used to auto-include dependencies when a user selects a feature.
123
- const FEATURE_DEPENDENCIES = {
124
- 'home-page': [],
125
- 'about-page': ['home-page'],
126
- 'contact-page': ['home-page'],
127
- 'services-page': ['home-page'],
128
- 'responsive-navbar': ['home-page'],
129
- 'contact-form': ['contact-page'],
130
- 'site-footer': ['home-page', 'about-page', 'contact-page'],
131
- 'theme-switcher': ['home-page', 'about-page', 'contact-page'],
132
- };
133
-
134
- function resolveFeatureDependencies(selectedSlugs) {
135
- const resolved = new Set(selectedSlugs);
136
- let changed = true;
137
- while (changed) {
138
- changed = false;
139
- for (const slug of [...resolved]) {
140
- const deps = FEATURE_DEPENDENCIES[slug] || [];
141
- for (const dep of deps) {
142
- if (!resolved.has(dep)) {
143
- resolved.add(dep);
144
- changed = true;
145
- }
146
- }
147
- }
148
- }
149
- return [...resolved];
150
- }
151
-
152
- // --- Starter template utilities ---
153
-
154
- async function parseStarterDescription(filePath) {
155
- const content = await readFile(filePath, 'utf-8');
156
- const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
157
- if (!match) return '';
158
- const descLine = match[1].split('\n').find((l) => l.startsWith('description:'));
159
- return descLine ? descLine.replace(/^description:\s*/, '').trim() : '';
160
- }
161
-
162
- async function listStarterTemplates(category) {
163
- const dir = join(STARTERS_DIR, category);
164
- let entries;
165
- try {
166
- entries = await readdir(dir);
167
- } catch (e) {
168
- if (e.code === 'ENOENT') return [];
169
- throw e;
170
- }
171
- const mdFiles = entries.filter((f) => f.endsWith('.md'));
172
- const templates = [];
173
- for (const f of mdFiles) {
174
- const slug = f.replace(/\.md$/, '');
175
- const description = await parseStarterDescription(join(dir, f));
176
- templates.push({ slug, description });
177
- }
178
- return templates;
179
- }
180
-
181
121
  function formatStarterName(slug) {
182
122
  if (slug === '_none') return 'None';
183
123
  return slug
@@ -186,10 +126,6 @@ function formatStarterName(slug) {
186
126
  .join(' ');
187
127
  }
188
128
 
189
- function stampVersion(content) {
190
- return content.replace(/^(---\s*\n[\s\S]*?)gspec-version:\s*.+/m, `$1gspec-version: ${pkg.version}`);
191
- }
192
-
193
129
  function promptSelect(message, choices) {
194
130
  const rl = createInterface({ input: process.stdin, output: process.stdout });
195
131
  console.log(chalk.bold(`\n${message}\n`));
@@ -239,74 +175,119 @@ function promptMultiSelect(message, choices) {
239
175
  });
240
176
  }
241
177
 
242
- async function seedStarterTemplates(cwd) {
243
- const wantStarters = await promptConfirm(chalk.bold(' Would you like to start from a starter template? [y/N]: '));
244
- if (!wantStarters) {
245
- console.log(chalk.dim('\n Skipped starter templates.\n'));
246
- return;
247
- }
248
-
249
- const practices = await listStarterTemplates('practices');
250
- const stacks = await listStarterTemplates('stacks');
251
- const styles = await listStarterTemplates('styles');
252
- const features = await listStarterTemplates('features');
178
+ async function seedFromSavedSpecs(cwd) {
179
+ // Skip if gspec specs already exist in the project
180
+ try {
181
+ const existingFiles = await collectGspecFiles(join(cwd, 'gspec'));
182
+ if (existingFiles.length > 0) {
183
+ return;
184
+ }
185
+ } catch {}
253
186
 
254
- if (practices.length === 0 || stacks.length === 0 || styles.length === 0) {
255
- console.log(chalk.yellow(' Missing starter templates (practices, stacks, or styles). Skipping.\n'));
256
- return;
257
- }
187
+ // Check if ~/.gspec/ has any saved specs or playbooks
188
+ const gspecHome = join(homedir(), '.gspec');
189
+ let hasPlaybooks = false;
190
+ let playbooks = [];
191
+ try {
192
+ const pbEntries = await readdir(join(gspecHome, 'playbooks'));
193
+ playbooks = pbEntries.filter((f) => f.endsWith('.md'));
194
+ hasPlaybooks = playbooks.length > 0;
195
+ } catch {}
258
196
 
259
- const NONE_OPTION = { slug: '_none', description: 'I will define my own' };
197
+ let savedTypes = [];
198
+ try {
199
+ const entries = await readdir(gspecHome);
200
+ for (const entry of entries) {
201
+ if (entry === 'playbooks') continue;
202
+ try {
203
+ const info = await stat(join(gspecHome, entry));
204
+ if (info.isDirectory()) {
205
+ const files = await readdir(join(gspecHome, entry));
206
+ if (files.filter((f) => f.endsWith('.md')).length > 0) {
207
+ savedTypes.push(entry);
208
+ }
209
+ }
210
+ } catch {}
211
+ }
212
+ } catch {}
260
213
 
261
- // Single-select with auto-select for single-option categories
262
- const practice = practices.length === 1
263
- ? (console.log(chalk.dim(`\n Using practice: ${formatStarterName(practices[0].slug)}`)), practices[0].slug)
264
- : await promptSelect('Select a development practice', [...practices, NONE_OPTION]);
214
+ // Nothing saved skip silently
215
+ if (!hasPlaybooks && savedTypes.length === 0) return;
265
216
 
266
- const stack = stacks.length === 1
267
- ? (console.log(chalk.dim(` Using stack: ${formatStarterName(stacks[0].slug)}`)), stacks[0].slug)
268
- : await promptSelect('Select a technology stack', [...stacks, NONE_OPTION]);
217
+ const wantSaved = await promptConfirm(chalk.bold(' Would you like to start from saved specs in ~/.gspec/? [y/N]: '));
218
+ if (!wantSaved) {
219
+ console.log(chalk.dim('\n Skipped saved specs.\n'));
220
+ return;
221
+ }
269
222
 
270
- const style = styles.length === 1
271
- ? (console.log(chalk.dim(` Using style: ${formatStarterName(styles[0].slug)}`)), styles[0].slug)
272
- : await promptSelect('Select a visual style', [...styles, NONE_OPTION]);
223
+ // If playbooks exist, offer them first
224
+ if (hasPlaybooks) {
225
+ const playbookChoices = [];
226
+ for (const f of playbooks) {
227
+ const slug = f.replace(/\.md$/, '');
228
+ const content = await readFile(join(gspecHome, 'playbooks', f), 'utf-8');
229
+ const { fields } = parseFrontmatter(content);
230
+ playbookChoices.push({ slug, description: fields.description || '' });
231
+ }
273
232
 
274
- let selectedFeatures = [];
275
- if (features.length > 0) {
276
- selectedFeatures = await promptMultiSelect('Select features (optional)', [...features, NONE_OPTION]);
277
- selectedFeatures = selectedFeatures.filter(f => f !== '_none');
278
- }
233
+ const INDIVIDUAL_OPTION = { slug: '_individual', description: 'Pick individual specs instead' };
234
+ const selected = await promptSelect('Select a playbook', [...playbookChoices, INDIVIDUAL_OPTION]);
279
235
 
280
- // Auto-include feature dependencies
281
- if (selectedFeatures.length > 0) {
282
- const resolved = resolveFeatureDependencies(selectedFeatures);
283
- const added = resolved.filter((f) => !selectedFeatures.includes(f));
284
- if (added.length > 0) {
285
- console.log(chalk.cyan(`\n Auto-including dependencies: ${added.map(formatStarterName).join(', ')}`));
286
- selectedFeatures = resolved;
236
+ if (selected !== '_individual') {
237
+ await restorePlaybook(selected, cwd);
238
+ return;
287
239
  }
288
240
  }
289
241
 
290
- // Check for existing files
242
+ // Individual spec selection from ~/.gspec/
243
+ const NONE_OPTION = { slug: '_none', description: 'Skip' };
291
244
  const gspecDir = join(cwd, 'gspec');
292
245
  const filesToWrite = [];
293
- if (practice !== '_none') {
294
- filesToWrite.push({ src: join(STARTERS_DIR, 'practices', `${practice}.md`), dest: join(gspecDir, 'practices.md'), label: 'gspec/practices.md' });
295
- }
296
- if (stack !== '_none') {
297
- filesToWrite.push({ src: join(STARTERS_DIR, 'stacks', `${stack}.md`), dest: join(gspecDir, 'stack.md'), label: 'gspec/stack.md' });
298
- }
299
- if (style !== '_none') {
300
- filesToWrite.push({ src: join(STARTERS_DIR, 'styles', `${style}.md`), dest: join(gspecDir, 'style.md'), label: 'gspec/style.md' });
246
+
247
+ const CATEGORY_ORDER = [
248
+ { type: 'profiles', label: 'Select a profile', dest: 'profile.md', mode: 'single' },
249
+ { type: 'practices', label: 'Select practices', dest: 'practices.md', mode: 'single' },
250
+ { type: 'stacks', label: 'Select a stack', dest: 'stack.md', mode: 'single' },
251
+ { type: 'styles', label: 'Select a style', dest: 'style.md', mode: 'single' },
252
+ { type: 'features', label: 'Select features (optional)', dest: null, mode: 'multi' },
253
+ ];
254
+
255
+ for (const cat of CATEGORY_ORDER) {
256
+ if (!savedTypes.includes(cat.type)) continue;
257
+
258
+ const specs = await listSavedSpecs(cat.type);
259
+ if (specs.length === 0) continue;
260
+
261
+ if (cat.mode === 'single') {
262
+ const selected = specs.length === 1
263
+ ? (console.log(chalk.dim(`\n Using ${cat.type}: ${formatStarterName(specs[0].slug)}`)), specs[0].slug)
264
+ : await promptSelect(cat.label, [...specs, NONE_OPTION]);
265
+
266
+ if (selected !== '_none') {
267
+ filesToWrite.push({
268
+ src: join(gspecHome, cat.type, `${selected}.md`),
269
+ dest: join(gspecDir, cat.dest),
270
+ label: `gspec/${cat.dest}`,
271
+ });
272
+ }
273
+ } else {
274
+ let selectedSlugs = await promptMultiSelect(cat.label, specs);
275
+ for (const slug of selectedSlugs) {
276
+ filesToWrite.push({
277
+ src: join(gspecHome, cat.type, `${slug}.md`),
278
+ dest: join(gspecDir, 'features', `${slug}.md`),
279
+ label: `gspec/features/${slug}.md`,
280
+ });
281
+ }
282
+ }
301
283
  }
302
- for (const feature of selectedFeatures) {
303
- filesToWrite.push({
304
- src: join(STARTERS_DIR, 'features', `${feature}.md`),
305
- dest: join(gspecDir, 'features', `${feature}.md`),
306
- label: `gspec/features/${feature}.md`,
307
- });
284
+
285
+ if (filesToWrite.length === 0) {
286
+ console.log(chalk.dim('\n No specs selected. You can define specs using gspec commands.\n'));
287
+ return;
308
288
  }
309
289
 
290
+ // Check for existing files
310
291
  const existingFiles = [];
311
292
  for (const f of filesToWrite) {
312
293
  try {
@@ -317,11 +298,6 @@ async function seedStarterTemplates(cwd) {
317
298
  }
318
299
  }
319
300
 
320
- if (filesToWrite.length === 0) {
321
- console.log(chalk.dim('\n No starter templates selected. You can define these files using gspec commands.\n'));
322
- return;
323
- }
324
-
325
301
  if (existingFiles.length > 0) {
326
302
  console.log(chalk.yellow(`\n The following files already exist and will be overwritten:\n`));
327
303
  for (const label of existingFiles) {
@@ -330,31 +306,36 @@ async function seedStarterTemplates(cwd) {
330
306
  console.log();
331
307
  const confirmed = await promptConfirm(chalk.bold(' Continue and overwrite? [y/N]: '));
332
308
  if (!confirmed) {
333
- console.log(chalk.dim('\n Skipped starter templates.\n'));
309
+ console.log(chalk.dim('\n Skipped saved specs.\n'));
334
310
  return;
335
311
  }
336
312
  }
337
313
 
338
- // Copy files with version stamping
339
- console.log(chalk.bold('\n Seeding starter templates...\n'));
314
+ // Copy files
315
+ console.log(chalk.bold('\n Restoring saved specs...\n'));
316
+ const outdated = [];
340
317
  for (const f of filesToWrite) {
341
318
  await mkdir(dirname(f.dest), { recursive: true });
342
319
  const content = await readFile(f.src, 'utf-8');
343
- await writeFile(f.dest, stampVersion(content), 'utf-8');
320
+ await writeFile(f.dest, content, 'utf-8');
344
321
  console.log(` ${chalk.green('+')} ${f.label}`);
322
+
323
+ const version = parseSpecVersion(content);
324
+ if (version && version !== SPEC_VERSION) {
325
+ outdated.push({ label: f.label, version });
326
+ }
345
327
  }
346
328
 
347
- // Summary
348
- console.log(chalk.bold('\n Seeded gspec/ with:'));
349
- console.log(` Practice: ${practice === '_none' ? chalk.dim('(will define)') : formatStarterName(practice)}`);
350
- console.log(` Stack: ${stack === '_none' ? chalk.dim('(will define)') : formatStarterName(stack)}`);
351
- console.log(` Style: ${style === '_none' ? chalk.dim('(will define)') : formatStarterName(style)}`);
352
- if (selectedFeatures.length > 0) {
353
- console.log(` Features: ${selectedFeatures.map(formatStarterName).join(', ')}`);
354
- } else {
355
- console.log(` Features: ${chalk.dim('(will define)')}`);
329
+ console.log(chalk.green(`\n ✓ Restored ${filesToWrite.length} spec${filesToWrite.length === 1 ? '' : 's'} into gspec/\n`));
330
+
331
+ if (outdated.length > 0) {
332
+ console.log(chalk.yellow(' The following restored specs are outdated:\n'));
333
+ for (const o of outdated) {
334
+ console.log(` ${chalk.yellow('!')} ${o.label} version ${o.version} (current: ${SPEC_VERSION})`);
335
+ }
336
+ console.log();
337
+ console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update them to the current format.\n`));
356
338
  }
357
- console.log();
358
339
  }
359
340
 
360
341
  async function findExistingFiles(target, cwd) {
@@ -568,11 +549,14 @@ const MIGRATE_COMMANDS = {
568
549
  opencode: '/gspec-migrate',
569
550
  };
570
551
 
571
- function parseGspecVersion(content) {
552
+ function parseSpecVersion(content) {
572
553
  const match = content.match(/^---\n([\s\S]*?)\n---/);
573
554
  if (!match) return null;
574
- const versionMatch = match[1].match(/^gspec-version:\s*(.+)$/m);
575
- return versionMatch ? versionMatch[1].trim() : null;
555
+ const newMatch = match[1].match(/^spec-version:\s*(.+)$/m);
556
+ if (newMatch) return newMatch[1].trim();
557
+ const oldMatch = match[1].match(/^gspec-version:\s*(.+)$/m);
558
+ if (oldMatch) return oldMatch[1].trim();
559
+ return null;
576
560
  }
577
561
 
578
562
  async function collectGspecFiles(gspecDir) {
@@ -617,8 +601,8 @@ async function checkGspecFiles(cwd, targetName) {
617
601
  const outdated = [];
618
602
  for (const file of files) {
619
603
  const content = await readFile(file.path, 'utf-8');
620
- const version = parseGspecVersion(content);
621
- if (version === pkg.version) continue;
604
+ const version = parseSpecVersion(content);
605
+ if (version === SPEC_VERSION) continue;
622
606
  outdated.push({
623
607
  label: file.label,
624
608
  version,
@@ -630,8 +614,8 @@ async function checkGspecFiles(cwd, targetName) {
630
614
  console.log(chalk.yellow(` Found existing gspec files that may need updating:\n`));
631
615
  for (const file of outdated) {
632
616
  const status = file.version
633
- ? `version ${file.version} (current: ${pkg.version})`
634
- : `no version (pre-${pkg.version})`;
617
+ ? `version ${file.version} (current: ${SPEC_VERSION})`
618
+ : `no version (pre-${SPEC_VERSION})`;
635
619
  console.log(` ${chalk.yellow('!')} ${file.label} — ${status}`);
636
620
  }
637
621
  console.log();
@@ -644,6 +628,600 @@ async function checkGspecFiles(cwd, targetName) {
644
628
  console.log(` ${chalk.cyan(cmd)}\n`);
645
629
  }
646
630
 
631
+ // --- Save / Restore ---
632
+
633
+ const GSPEC_HOME = join(homedir(), '.gspec');
634
+
635
+ // Map gspec/ file paths to save/restore type folders
636
+ const GSPEC_TYPE_MAP = {
637
+ 'profile.md': 'profiles',
638
+ 'stack.md': 'stacks',
639
+ 'style.md': 'styles',
640
+ 'practices.md': 'practices',
641
+ };
642
+
643
+ // Reverse: restore type folder → gspec/ destination filename
644
+ const RESTORE_DEST_MAP = {
645
+ profiles: 'profile.md',
646
+ stacks: 'stack.md',
647
+ styles: 'style.md',
648
+ practices: 'practices.md',
649
+ features: null, // features keep their own filename
650
+ };
651
+
652
+ function parseFrontmatter(content) {
653
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
654
+ if (!match) return { fields: {}, body: content };
655
+ const fields = {};
656
+ for (const line of match[1].split('\n')) {
657
+ const idx = line.indexOf(':');
658
+ if (idx > 0) {
659
+ fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
660
+ }
661
+ }
662
+ return { fields, body: content.slice(match[0].length) };
663
+ }
664
+
665
+ function setFrontmatterField(content, key, value) {
666
+ const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
667
+ if (!match) {
668
+ // No frontmatter — create one
669
+ return `---\n${key}: ${value}\n---\n${content}`;
670
+ }
671
+ const lines = match[2].split('\n');
672
+ const existing = lines.findIndex((l) => l.startsWith(`${key}:`));
673
+ if (existing >= 0) {
674
+ lines[existing] = `${key}: ${value}`;
675
+ } else {
676
+ // Insert name as first field
677
+ lines.unshift(`${key}: ${value}`);
678
+ }
679
+ return `${match[1]}${lines.join('\n')}${match[3]}${content.slice(match[0].length)}`;
680
+ }
681
+
682
+ function promptInput(message) {
683
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
684
+ return new Promise((resolve) => {
685
+ rl.question(message, (answer) => {
686
+ rl.close();
687
+ resolve(answer.trim());
688
+ });
689
+ });
690
+ }
691
+
692
+ async function collectSavableFiles(cwd) {
693
+ const gspecDir = join(cwd, 'gspec');
694
+ const files = [];
695
+
696
+ try {
697
+ await stat(gspecDir);
698
+ } catch (e) {
699
+ if (e.code === 'ENOENT') return files;
700
+ throw e;
701
+ }
702
+
703
+ // Top-level spec files
704
+ const topEntries = await readdir(gspecDir);
705
+ for (const entry of topEntries) {
706
+ if (!entry.endsWith('.md') || entry.toLowerCase() === 'readme.md') continue;
707
+ const type = GSPEC_TYPE_MAP[entry];
708
+ if (!type) continue;
709
+ files.push({
710
+ path: join(gspecDir, entry),
711
+ type,
712
+ label: `gspec/${entry}`,
713
+ });
714
+ }
715
+
716
+ // Feature files
717
+ try {
718
+ const featureEntries = await readdir(join(gspecDir, 'features'));
719
+ for (const entry of featureEntries) {
720
+ if (!entry.endsWith('.md')) continue;
721
+ files.push({
722
+ path: join(gspecDir, 'features', entry),
723
+ type: 'features',
724
+ label: `gspec/features/${entry}`,
725
+ });
726
+ }
727
+ } catch (e) {
728
+ if (e.code !== 'ENOENT') throw e;
729
+ }
730
+
731
+ return files;
732
+ }
733
+
734
+ async function saveSpec(cwd) {
735
+ console.log(BANNER);
736
+
737
+ const files = await collectSavableFiles(cwd);
738
+ if (files.length === 0) {
739
+ console.error(chalk.red('\n No gspec files found in gspec/ directory.\n'));
740
+ process.exit(1);
741
+ }
742
+
743
+ // Let user select which file to save
744
+ console.log(chalk.bold('\n Which spec would you like to save?\n'));
745
+ for (let i = 0; i < files.length; i++) {
746
+ const content = await readFile(files[i].path, 'utf-8');
747
+ const { fields } = parseFrontmatter(content);
748
+ const desc = fields.description ? ` — ${fields.description}` : '';
749
+ console.log(` ${chalk.cyan(String(i + 1))}) ${files[i].label}${chalk.dim(desc)}`);
750
+ }
751
+ console.log();
752
+
753
+ const answer = await promptInput(chalk.bold(` Select [1-${files.length}]: `));
754
+ const num = parseInt(answer, 10);
755
+ if (isNaN(num) || num < 1 || num > files.length) {
756
+ console.error(chalk.red(`\n Invalid selection: "${answer}"`));
757
+ process.exit(1);
758
+ }
759
+
760
+ const selected = files[num - 1];
761
+
762
+ // Prompt for name
763
+ const name = await promptInput(chalk.bold('\n Save name (no spaces, e.g. my-saas-stack): '));
764
+ if (!name) {
765
+ console.error(chalk.red('\n Name is required.'));
766
+ process.exit(1);
767
+ }
768
+ if (/\s/.test(name)) {
769
+ console.error(chalk.red('\n Name cannot contain spaces. Use hyphens instead (e.g. my-saas-stack).'));
770
+ process.exit(1);
771
+ }
772
+
773
+ // Read content and update frontmatter with name
774
+ let content = await readFile(selected.path, 'utf-8');
775
+ content = setFrontmatterField(content, 'name', name);
776
+
777
+ // Ensure description exists
778
+ const { fields } = parseFrontmatter(content);
779
+ if (!fields.description) {
780
+ const desc = await promptInput(chalk.bold(' Description (short summary): '));
781
+ if (desc) {
782
+ content = setFrontmatterField(content, 'description', desc);
783
+ }
784
+ }
785
+
786
+ // Write to ~/.gspec/{type}/{name}.md
787
+ const destDir = join(GSPEC_HOME, selected.type);
788
+ const destPath = join(destDir, `${name}.md`);
789
+ await mkdir(destDir, { recursive: true });
790
+
791
+ // Check if file already exists
792
+ try {
793
+ await stat(destPath);
794
+ const overwrite = await promptConfirm(chalk.yellow(`\n ${selected.type}/${name} already exists. Overwrite? [y/N]: `));
795
+ if (!overwrite) {
796
+ console.log(chalk.dim('\n Save cancelled.\n'));
797
+ return;
798
+ }
799
+ } catch (e) {
800
+ if (e.code !== 'ENOENT') throw e;
801
+ }
802
+
803
+ // Uncheck all implementation checkboxes so saved specs start fresh
804
+ content = content.replace(/- \[x\]/g, '- [ ]');
805
+
806
+ await writeFile(destPath, content, 'utf-8');
807
+ console.log(chalk.green(`\n ✓ Saved to ~/.gspec/${selected.type}/${name}.md\n`));
808
+ }
809
+
810
+ async function listSavedTypes() {
811
+ const types = [];
812
+ try {
813
+ const entries = await readdir(GSPEC_HOME);
814
+ for (const entry of entries) {
815
+ try {
816
+ const info = await stat(join(GSPEC_HOME, entry));
817
+ if (info.isDirectory()) {
818
+ const files = await readdir(join(GSPEC_HOME, entry));
819
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
820
+ if (mdFiles.length > 0) types.push(entry);
821
+ }
822
+ } catch { /* skip */ }
823
+ }
824
+ } catch (e) {
825
+ if (e.code === 'ENOENT') return types;
826
+ throw e;
827
+ }
828
+ return types;
829
+ }
830
+
831
+ async function listSavedSpecs(type) {
832
+ const dir = join(GSPEC_HOME, type);
833
+ const entries = await readdir(dir);
834
+ const specs = [];
835
+ for (const entry of entries) {
836
+ if (!entry.endsWith('.md')) continue;
837
+ const content = await readFile(join(dir, entry), 'utf-8');
838
+ const { fields } = parseFrontmatter(content);
839
+ specs.push({
840
+ slug: entry.replace(/\.md$/, ''),
841
+ description: fields.description || '',
842
+ });
843
+ }
844
+ return specs;
845
+ }
846
+
847
+ async function restoreSpec(specPath, cwd) {
848
+ console.log(BANNER);
849
+
850
+ if (specPath) {
851
+ // Direct restore: e.g. "stacks/web", "features/auth-flow", or "playbook/my-starter"
852
+ const parts = specPath.split('/');
853
+ if (parts.length !== 2) {
854
+ console.error(chalk.red(`\n Invalid format. Use: type/name (e.g. stacks/my-stack, playbook/my-starter)\n`));
855
+ process.exit(1);
856
+ }
857
+ const [type, name] = parts;
858
+ if (type === 'playbook' || type === 'playbooks') {
859
+ await restorePlaybook(name, cwd);
860
+ } else {
861
+ await restoreFile(type, name, cwd);
862
+ }
863
+ return;
864
+ }
865
+
866
+ // Interactive: pick type, then file
867
+ const types = await listSavedTypes();
868
+ if (types.length === 0) {
869
+ console.error(chalk.red('\n No saved specs found in ~/.gspec/'));
870
+ console.error(chalk.dim(' Use "gspec save" to save specs first.\n'));
871
+ process.exit(1);
872
+ }
873
+
874
+ console.log(chalk.bold('\n Select a spec type:\n'));
875
+ for (let i = 0; i < types.length; i++) {
876
+ console.log(` ${chalk.cyan(String(i + 1))}) ${types[i]}`);
877
+ }
878
+ console.log();
879
+
880
+ const typeAnswer = await promptInput(chalk.bold(` Select [1-${types.length}]: `));
881
+ const typeNum = parseInt(typeAnswer, 10);
882
+ if (isNaN(typeNum) || typeNum < 1 || typeNum > types.length) {
883
+ console.error(chalk.red(`\n Invalid selection: "${typeAnswer}"`));
884
+ process.exit(1);
885
+ }
886
+
887
+ const selectedType = types[typeNum - 1];
888
+ const specs = await listSavedSpecs(selectedType);
889
+
890
+ if (specs.length === 0) {
891
+ console.error(chalk.red(`\n No specs found in ~/.gspec/${selectedType}/\n`));
892
+ process.exit(1);
893
+ }
894
+
895
+ console.log(chalk.bold(`\n Select a spec from ${selectedType}:\n`));
896
+ for (let i = 0; i < specs.length; i++) {
897
+ const desc = specs[i].description ? ` — ${specs[i].description}` : '';
898
+ console.log(` ${chalk.cyan(String(i + 1))}) ${specs[i].slug}${chalk.dim(desc)}`);
899
+ }
900
+ console.log();
901
+
902
+ const specAnswer = await promptInput(chalk.bold(` Select [1-${specs.length}]: `));
903
+ const specNum = parseInt(specAnswer, 10);
904
+ if (isNaN(specNum) || specNum < 1 || specNum > specs.length) {
905
+ console.error(chalk.red(`\n Invalid selection: "${specAnswer}"`));
906
+ process.exit(1);
907
+ }
908
+
909
+ await restoreFile(selectedType, specs[specNum - 1].slug, cwd);
910
+ }
911
+
912
+ async function restoreFile(type, name, cwd) {
913
+ const srcPath = join(GSPEC_HOME, type, `${name}.md`);
914
+
915
+ try {
916
+ await stat(srcPath);
917
+ } catch (e) {
918
+ if (e.code === 'ENOENT') {
919
+ console.error(chalk.red(`\n Not found: ~/.gspec/${type}/${name}.md\n`));
920
+ process.exit(1);
921
+ }
922
+ throw e;
923
+ }
924
+
925
+ const gspecDir = join(cwd, 'gspec');
926
+ let destPath;
927
+
928
+ if (type === 'features') {
929
+ destPath = join(gspecDir, 'features', `${name}.md`);
930
+ } else {
931
+ const destFile = RESTORE_DEST_MAP[type];
932
+ if (!destFile) {
933
+ console.error(chalk.red(`\n Unknown spec type: ${type}\n`));
934
+ process.exit(1);
935
+ }
936
+ destPath = join(gspecDir, destFile);
937
+ }
938
+
939
+ // Check for existing file
940
+ try {
941
+ await stat(destPath);
942
+ const relPath = destPath.slice(cwd.length + 1);
943
+ const overwrite = await promptConfirm(chalk.yellow(`\n ${relPath} already exists. Overwrite? [y/N]: `));
944
+ if (!overwrite) {
945
+ console.log(chalk.dim('\n Restore cancelled.\n'));
946
+ return;
947
+ }
948
+ } catch (e) {
949
+ if (e.code !== 'ENOENT') throw e;
950
+ }
951
+
952
+ await mkdir(dirname(destPath), { recursive: true });
953
+ const content = await readFile(srcPath, 'utf-8');
954
+ await writeFile(destPath, content, 'utf-8');
955
+
956
+ const relDest = destPath.slice(cwd.length + 1);
957
+ console.log(chalk.green(`\n ✓ Restored ${type}/${name} → ${relDest}\n`));
958
+
959
+ const version = parseSpecVersion(content);
960
+ if (version && version !== SPEC_VERSION) {
961
+ console.log(chalk.yellow(` ⚠ ${relDest} is version ${version} (current: ${SPEC_VERSION})`));
962
+ console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update it to the current format.\n`));
963
+ }
964
+ }
965
+
966
+ // --- Playbooks ---
967
+
968
+ async function listSavedSpecsSafe(type) {
969
+ try {
970
+ return await listSavedSpecs(type);
971
+ } catch (e) {
972
+ if (e.code === 'ENOENT') return [];
973
+ throw e;
974
+ }
975
+ }
976
+
977
+ async function createPlaybook() {
978
+ console.log(BANNER);
979
+ console.log(chalk.bold('\n Create a playbook\n'));
980
+ console.log(chalk.dim(' A playbook bundles saved specs so you can restore them all at once.\n'));
981
+
982
+ const NONE_OPTION = { slug: '_none', description: 'Skip' };
983
+
984
+ // --- Profile (0 or 1) ---
985
+ const profiles = await listSavedSpecsSafe('profiles');
986
+ let profile = null;
987
+ if (profiles.length > 0) {
988
+ profile = await promptSelect('Select a profile (or skip)', [...profiles, NONE_OPTION]);
989
+ if (profile === '_none') profile = null;
990
+ } else {
991
+ console.log(chalk.dim(' No saved profiles found — skipping.\n'));
992
+ }
993
+
994
+ // --- Stack (0 or 1) ---
995
+ const stacks = await listSavedSpecsSafe('stacks');
996
+ let stack = null;
997
+ if (stacks.length > 0) {
998
+ stack = await promptSelect('Select a stack (or skip)', [...stacks, NONE_OPTION]);
999
+ if (stack === '_none') stack = null;
1000
+ } else {
1001
+ console.log(chalk.dim(' No saved stacks found — skipping.\n'));
1002
+ }
1003
+
1004
+ // --- Practices (0 or 1) ---
1005
+ const practices = await listSavedSpecsSafe('practices');
1006
+ let practice = null;
1007
+ if (practices.length > 0) {
1008
+ practice = await promptSelect('Select practices (or skip)', [...practices, NONE_OPTION]);
1009
+ if (practice === '_none') practice = null;
1010
+ } else {
1011
+ console.log(chalk.dim(' No saved practices found — skipping.\n'));
1012
+ }
1013
+
1014
+ // --- Style (0 or 1) ---
1015
+ const styles = await listSavedSpecsSafe('styles');
1016
+ let style = null;
1017
+ if (styles.length > 0) {
1018
+ style = await promptSelect('Select a style (or skip)', [...styles, NONE_OPTION]);
1019
+ if (style === '_none') style = null;
1020
+ } else {
1021
+ console.log(chalk.dim(' No saved styles found — skipping.\n'));
1022
+ }
1023
+
1024
+ // --- Features (0 to many) ---
1025
+ const features = await listSavedSpecsSafe('features');
1026
+ let selectedFeatures = [];
1027
+ if (features.length > 0) {
1028
+ selectedFeatures = await promptMultiSelect('Select features (optional)', features);
1029
+ } else {
1030
+ console.log(chalk.dim(' No saved features found — skipping.\n'));
1031
+ }
1032
+
1033
+ // Check that at least one spec was selected
1034
+ if (!profile && !stack && !practice && !style && selectedFeatures.length === 0) {
1035
+ console.error(chalk.red('\n No specs selected. Playbook not created.\n'));
1036
+ process.exit(1);
1037
+ }
1038
+
1039
+ // Prompt for playbook name
1040
+ const name = await promptInput(chalk.bold('\n Playbook name (no spaces, e.g. my-saas-starter): '));
1041
+ if (!name) {
1042
+ console.error(chalk.red('\n Name is required.'));
1043
+ process.exit(1);
1044
+ }
1045
+ if (/\s/.test(name)) {
1046
+ console.error(chalk.red('\n Name cannot contain spaces. Use hyphens instead (e.g. my-saas-starter).'));
1047
+ process.exit(1);
1048
+ }
1049
+
1050
+ // Prompt for description
1051
+ const description = await promptInput(chalk.bold(' Description (short summary): '));
1052
+
1053
+ // Build playbook content
1054
+ const lines = ['---'];
1055
+ lines.push(`name: ${name}`);
1056
+ if (description) lines.push(`description: ${description}`);
1057
+ lines.push('---', '');
1058
+ if (profile) lines.push(`profile: ${profile}`);
1059
+ if (stack) lines.push(`stack: ${stack}`);
1060
+ if (practice) lines.push(`practices: ${practice}`);
1061
+ if (style) lines.push(`style: ${style}`);
1062
+ if (selectedFeatures.length > 0) {
1063
+ lines.push('features:');
1064
+ for (const f of selectedFeatures) {
1065
+ lines.push(` - ${f}`);
1066
+ }
1067
+ }
1068
+ lines.push('');
1069
+
1070
+ // Write playbook
1071
+ const destDir = join(GSPEC_HOME, 'playbooks');
1072
+ const destPath = join(destDir, `${name}.md`);
1073
+ await mkdir(destDir, { recursive: true });
1074
+
1075
+ try {
1076
+ await stat(destPath);
1077
+ const overwrite = await promptConfirm(chalk.yellow(`\n Playbook "${name}" already exists. Overwrite? [y/N]: `));
1078
+ if (!overwrite) {
1079
+ console.log(chalk.dim('\n Cancelled.\n'));
1080
+ return;
1081
+ }
1082
+ } catch (e) {
1083
+ if (e.code !== 'ENOENT') throw e;
1084
+ }
1085
+
1086
+ await writeFile(destPath, lines.join('\n'), 'utf-8');
1087
+
1088
+ // Summary
1089
+ console.log(chalk.green(`\n ✓ Playbook saved to ~/.gspec/playbooks/${name}.md\n`));
1090
+ console.log(chalk.bold(' Contents:'));
1091
+ if (profile) console.log(` Profile: ${formatStarterName(profile)}`);
1092
+ if (stack) console.log(` Stack: ${formatStarterName(stack)}`);
1093
+ if (practice) console.log(` Practices: ${formatStarterName(practice)}`);
1094
+ if (style) console.log(` Style: ${formatStarterName(style)}`);
1095
+ if (selectedFeatures.length > 0) {
1096
+ console.log(` Features: ${selectedFeatures.map(formatStarterName).join(', ')}`);
1097
+ }
1098
+ console.log();
1099
+ console.log(chalk.dim(` Restore with: gspec restore playbook/${name}\n`));
1100
+ }
1101
+
1102
+ function parsePlaybook(content) {
1103
+ const { fields } = parseFrontmatter(content);
1104
+ const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
1105
+ const result = { name: fields.name || '', description: fields.description || '' };
1106
+
1107
+ for (const line of body.split('\n')) {
1108
+ const match = line.match(/^(\w+):\s*(.+)$/);
1109
+ if (match) {
1110
+ const [, key, value] = match;
1111
+ if (key !== 'features') result[key] = value.trim();
1112
+ }
1113
+ }
1114
+
1115
+ // Parse features list
1116
+ const featuresMatch = body.match(/^features:\s*\n((?:\s+-\s+.+\n?)+)/m);
1117
+ if (featuresMatch) {
1118
+ result.features = featuresMatch[1]
1119
+ .split('\n')
1120
+ .map((l) => l.replace(/^\s+-\s+/, '').trim())
1121
+ .filter(Boolean);
1122
+ } else {
1123
+ result.features = [];
1124
+ }
1125
+
1126
+ return result;
1127
+ }
1128
+
1129
+ async function restorePlaybook(name, cwd) {
1130
+ const srcPath = join(GSPEC_HOME, 'playbooks', `${name}.md`);
1131
+
1132
+ try {
1133
+ await stat(srcPath);
1134
+ } catch (e) {
1135
+ if (e.code === 'ENOENT') {
1136
+ console.error(chalk.red(`\n Not found: ~/.gspec/playbooks/${name}.md\n`));
1137
+ process.exit(1);
1138
+ }
1139
+ throw e;
1140
+ }
1141
+
1142
+ const content = await readFile(srcPath, 'utf-8');
1143
+ const playbook = parsePlaybook(content);
1144
+
1145
+ console.log(chalk.bold(`\n Restoring playbook: ${playbook.name || name}\n`));
1146
+ if (playbook.description) console.log(chalk.dim(` ${playbook.description}\n`));
1147
+
1148
+ const gspecDir = join(cwd, 'gspec');
1149
+ await mkdir(gspecDir, { recursive: true });
1150
+
1151
+ const restorations = [];
1152
+ if (playbook.profile) restorations.push({ type: 'profiles', slug: playbook.profile });
1153
+ if (playbook.stack) restorations.push({ type: 'stacks', slug: playbook.stack });
1154
+ if (playbook.practices) restorations.push({ type: 'practices', slug: playbook.practices });
1155
+ if (playbook.style) restorations.push({ type: 'styles', slug: playbook.style });
1156
+ for (const f of playbook.features) {
1157
+ restorations.push({ type: 'features', slug: f });
1158
+ }
1159
+
1160
+ // Check for existing files
1161
+ const existing = [];
1162
+ for (const r of restorations) {
1163
+ const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
1164
+ const destPath = join(gspecDir, destFile);
1165
+ try {
1166
+ await stat(destPath);
1167
+ existing.push(`gspec/${destFile}`);
1168
+ } catch (e) {
1169
+ if (e.code !== 'ENOENT') throw e;
1170
+ }
1171
+ }
1172
+
1173
+ if (existing.length > 0) {
1174
+ console.log(chalk.yellow(' The following files already exist and will be overwritten:\n'));
1175
+ for (const label of existing) {
1176
+ console.log(` ${chalk.yellow('!')} ${label}`);
1177
+ }
1178
+ console.log();
1179
+ const confirmed = await promptConfirm(chalk.bold(' Continue and overwrite? [y/N]: '));
1180
+ if (!confirmed) {
1181
+ console.log(chalk.dim('\n Restore cancelled.\n'));
1182
+ return;
1183
+ }
1184
+ }
1185
+
1186
+ // Restore all specs
1187
+ const outdated = [];
1188
+ for (const r of restorations) {
1189
+ const srcFile = join(GSPEC_HOME, r.type, `${r.slug}.md`);
1190
+ try {
1191
+ await stat(srcFile);
1192
+ } catch (e) {
1193
+ if (e.code === 'ENOENT') {
1194
+ console.log(` ${chalk.yellow('!')} Skipped ${r.type}/${r.slug} — not found in ~/.gspec/`);
1195
+ continue;
1196
+ }
1197
+ throw e;
1198
+ }
1199
+
1200
+ const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
1201
+ const destPath = join(gspecDir, destFile);
1202
+ await mkdir(dirname(destPath), { recursive: true });
1203
+ const specContent = await readFile(srcFile, 'utf-8');
1204
+ await writeFile(destPath, specContent, 'utf-8');
1205
+ console.log(` ${chalk.green('+')} gspec/${destFile}`);
1206
+
1207
+ const version = parseSpecVersion(specContent);
1208
+ if (version && version !== SPEC_VERSION) {
1209
+ outdated.push({ label: `gspec/${destFile}`, version });
1210
+ }
1211
+ }
1212
+
1213
+ console.log(chalk.green(`\n ✓ Playbook "${playbook.name || name}" restored.\n`));
1214
+
1215
+ if (outdated.length > 0) {
1216
+ console.log(chalk.yellow(' ⚠ The following restored specs are outdated:\n'));
1217
+ for (const o of outdated) {
1218
+ console.log(` ${chalk.yellow('!')} ${o.label} — version ${o.version} (current: ${SPEC_VERSION})`);
1219
+ }
1220
+ console.log();
1221
+ console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update them to the current format.\n`));
1222
+ }
1223
+ }
1224
+
647
1225
  program
648
1226
  .name('gspec')
649
1227
  .description('Install gspec specification commands')
@@ -660,25 +1238,53 @@ program
660
1238
 
661
1239
  await install(targetName, process.cwd());
662
1240
 
663
- await seedStarterTemplates(process.cwd());
1241
+ await seedFromSavedSpecs(process.cwd());
664
1242
 
665
1243
  await installSpecSync(targetName, process.cwd());
666
1244
 
667
1245
  await checkGspecFiles(process.cwd(), targetName);
668
1246
 
669
- // Post-install: instruct user to generate profile.md
1247
+ // Post-install: instruct user to generate profile.md (only if it doesn't already exist)
1248
+ const profilePath = join(process.cwd(), 'gspec', 'profile.md');
1249
+ let profileExists = false;
1250
+ try { await stat(profilePath); profileExists = true; } catch {}
1251
+
670
1252
  const targetLabel = TARGETS[targetName].label;
671
- console.log();
672
- console.log(chalk.bold.cyan(' ═══ Next Step ═══════════════════════════════════════════════'));
673
- console.log();
674
- console.log(chalk.bold.white(' Generate your product profile before continuing.'));
675
- console.log();
676
- console.log(` Run ${chalk.bold.yellow('/gspec-profile')} in ${targetLabel} to create gspec/profile.md`);
677
- console.log(` it defines what your product is, who it serves, and why it`);
678
- console.log(` exists. All other gspec commands use the profile as their foundation.`);
679
- console.log();
680
- console.log(chalk.bold.cyan(' ═════════════════════════════════════════════════════════════'));
681
- console.log();
1253
+ if (!profileExists) {
1254
+ console.log();
1255
+ console.log(chalk.bold.cyan(' ═══ Next Step ═══════════════════════════════════════════════'));
1256
+ console.log();
1257
+ console.log(chalk.bold.white(' Generate your product profile before continuing.'));
1258
+ console.log();
1259
+ console.log(` Run ${chalk.bold.yellow('/gspec-profile')} in ${targetLabel} to create gspec/profile.md`);
1260
+ console.log(` it defines what your product is, who it serves, and why it`);
1261
+ console.log(` exists. All other gspec commands use the profile as their foundation.`);
1262
+ console.log();
1263
+ console.log(chalk.bold.cyan(' ═════════════════════════════════════════════════════════════'));
1264
+ console.log();
1265
+ }
1266
+ });
1267
+
1268
+ program
1269
+ .command('save')
1270
+ .description('Save a gspec spec to ~/.gspec for reuse across projects')
1271
+ .action(async () => {
1272
+ await saveSpec(process.cwd());
1273
+ });
1274
+
1275
+ program
1276
+ .command('restore')
1277
+ .description('Restore a saved spec from ~/.gspec into the current project')
1278
+ .argument('[spec]', 'spec to restore (e.g. stacks/my-stack, playbook/my-starter)')
1279
+ .action(async (spec) => {
1280
+ await restoreSpec(spec, process.cwd());
1281
+ });
1282
+
1283
+ program
1284
+ .command('playbook')
1285
+ .description('Create a playbook that bundles saved specs for quick project setup')
1286
+ .action(async () => {
1287
+ await createPlaybook();
682
1288
  });
683
1289
 
684
1290
  program.parse();