@esimplicitylabs/katalyst-xspec 0.6.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 (29) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +69 -0
  3. package/bin/katalyst-xspec.cjs +54 -0
  4. package/cli/init.cjs +679 -0
  5. package/cli/stubs.cjs +365 -0
  6. package/cli/upgrade.cjs +1014 -0
  7. package/dist/chunk-ACAXOGKZ.js +1611 -0
  8. package/dist/index.d.ts +881 -0
  9. package/dist/index.js +1091 -0
  10. package/dist/steps/index.d.ts +151 -0
  11. package/dist/steps/index.js +50 -0
  12. package/package.json +80 -0
  13. package/scripts/postinstall.cjs +85 -0
  14. package/skills/katalyst-bdd-architecture/SKILL.md +517 -0
  15. package/skills/katalyst-bdd-architecture/references/adapters.md +310 -0
  16. package/skills/katalyst-bdd-architecture/references/custom-steps.md +360 -0
  17. package/skills/katalyst-bdd-architecture/references/ports.md +256 -0
  18. package/skills/katalyst-bdd-create-test/SKILL.md +366 -0
  19. package/skills/katalyst-bdd-create-test/references/api-patterns.md +371 -0
  20. package/skills/katalyst-bdd-create-test/references/hybrid-patterns.md +420 -0
  21. package/skills/katalyst-bdd-create-test/references/tui-patterns.md +458 -0
  22. package/skills/katalyst-bdd-create-test/references/ui-patterns.md +415 -0
  23. package/skills/katalyst-bdd-quickstart/SKILL.md +292 -0
  24. package/skills/katalyst-bdd-step-reference/SKILL.md +147 -0
  25. package/skills/katalyst-bdd-step-reference/references/api-steps.md +247 -0
  26. package/skills/katalyst-bdd-step-reference/references/shared-steps.md +340 -0
  27. package/skills/katalyst-bdd-step-reference/references/tui-steps.md +483 -0
  28. package/skills/katalyst-bdd-step-reference/references/ui-steps.md +521 -0
  29. package/skills/katalyst-bdd-troubleshooting/SKILL.md +449 -0
package/cli/init.cjs ADDED
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs/promises');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+
8
+ // Agent skill directories configuration
9
+ const SKILL_AGENTS = {
10
+ 'opencode': '.opencode/skills',
11
+ 'claude-code': '.claude/skills',
12
+ 'cursor': '.cursor/skills',
13
+ 'generic': 'skills',
14
+ };
15
+
16
+ const SKILL_AGENT_LABELS = {
17
+ 'opencode': 'OpenCode (.opencode/skills/)',
18
+ 'claude-code': 'Claude Code (.claude/skills/)',
19
+ 'cursor': 'Cursor (.cursor/skills/)',
20
+ 'generic': 'Generic (skills/)',
21
+ };
22
+
23
+ async function pathExists(p) {
24
+ try {
25
+ await fs.access(p);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ async function promptYesNo(question) {
33
+ const rl = readline.createInterface({
34
+ input: process.stdin,
35
+ output: process.stdout,
36
+ });
37
+ return new Promise((resolve) => {
38
+ rl.question(`${question} (y/n) `, (answer) => {
39
+ rl.close();
40
+ resolve(answer.toLowerCase().startsWith('y'));
41
+ });
42
+ });
43
+ }
44
+
45
+ async function promptMultiSelect(question, options) {
46
+ const rl = readline.createInterface({
47
+ input: process.stdin,
48
+ output: process.stdout,
49
+ });
50
+
51
+ console.log(`\n${question}`);
52
+ options.forEach((opt, i) => {
53
+ console.log(` ${i + 1}. ${opt.label}`);
54
+ });
55
+
56
+ return new Promise((resolve) => {
57
+ rl.question('Enter numbers (comma-separated, e.g., 1,2,3): ', (answer) => {
58
+ rl.close();
59
+ const indices = answer.split(',')
60
+ .map(s => parseInt(s.trim(), 10) - 1)
61
+ .filter(i => i >= 0 && i < options.length);
62
+ const selected = indices.map(i => options[i].key);
63
+ resolve(selected.length > 0 ? selected : ['generic']); // Default to generic if none selected
64
+ });
65
+ });
66
+ }
67
+
68
+ async function copyDir(src, dest) {
69
+ await fs.mkdir(dest, { recursive: true });
70
+ const entries = await fs.readdir(src, { withFileTypes: true });
71
+
72
+ for (const entry of entries) {
73
+ const srcPath = path.join(src, entry.name);
74
+ const destPath = path.join(dest, entry.name);
75
+
76
+ if (entry.isDirectory()) {
77
+ await copyDir(srcPath, destPath);
78
+ } else {
79
+ await fs.copyFile(srcPath, destPath);
80
+ }
81
+ }
82
+ }
83
+
84
+ async function copySkillsToAgents(targetDir, agents, results) {
85
+ const skillsSourceDir = path.join(__dirname, '..', 'skills');
86
+
87
+ // Check if skills source exists
88
+ if (!(await pathExists(skillsSourceDir))) {
89
+ console.log(' Warning: Skills source directory not found, skipping skills installation');
90
+ return;
91
+ }
92
+
93
+ // Get list of skill directories
94
+ const skillDirs = await fs.readdir(skillsSourceDir, { withFileTypes: true });
95
+ const skills = skillDirs.filter(d => d.isDirectory()).map(d => d.name);
96
+
97
+ for (const agent of agents) {
98
+ const agentSkillsDir = path.join(targetDir, SKILL_AGENTS[agent]);
99
+
100
+ for (const skill of skills) {
101
+ const srcSkillDir = path.join(skillsSourceDir, skill);
102
+ const destSkillDir = path.join(agentSkillsDir, skill);
103
+
104
+ await copyDir(srcSkillDir, destSkillDir);
105
+ results.skills.push(path.relative(process.cwd(), destSkillDir));
106
+ }
107
+ }
108
+ }
109
+
110
+ async function ensureDir(dir) {
111
+ await fs.mkdir(dir, { recursive: true });
112
+ }
113
+
114
+ /**
115
+ * Parse .env file content into key-value pairs
116
+ * Preserves comments as separate entries
117
+ */
118
+ function parseEnvFile(content) {
119
+ const lines = content.split('\n');
120
+ const entries = [];
121
+
122
+ for (const line of lines) {
123
+ const trimmed = line.trim();
124
+
125
+ // Empty line
126
+ if (trimmed === '') {
127
+ entries.push({ type: 'empty' });
128
+ continue;
129
+ }
130
+
131
+ // Comment line
132
+ if (trimmed.startsWith('#')) {
133
+ entries.push({ type: 'comment', value: line });
134
+ continue;
135
+ }
136
+
137
+ // Key=value line
138
+ const eqIndex = line.indexOf('=');
139
+ if (eqIndex > 0) {
140
+ const key = line.substring(0, eqIndex).trim();
141
+ const value = line.substring(eqIndex + 1);
142
+ entries.push({ type: 'var', key, value, line });
143
+ }
144
+ }
145
+
146
+ return entries;
147
+ }
148
+
149
+ /**
150
+ * Merge two .env files, preserving existing variables and adding new ones
151
+ */
152
+ function mergeEnvFiles(existing, template) {
153
+ const existingEntries = parseEnvFile(existing);
154
+ const templateEntries = parseEnvFile(template);
155
+
156
+ // Get existing keys
157
+ const existingKeys = new Set(
158
+ existingEntries.filter(e => e.type === 'var').map(e => e.key)
159
+ );
160
+
161
+ // Find new variables from template that don't exist
162
+ const newVars = templateEntries.filter(e =>
163
+ e.type === 'var' && !existingKeys.has(e.key)
164
+ );
165
+
166
+ // If no new vars, return existing as-is
167
+ if (newVars.length === 0) {
168
+ return existing;
169
+ }
170
+
171
+ // Build merged content
172
+ let merged = existing;
173
+
174
+ // Add new variables at the end
175
+ if (!merged.endsWith('\n')) {
176
+ merged += '\n';
177
+ }
178
+ merged += '\n# New variables added by scaffolder update\n';
179
+
180
+ for (const entry of newVars) {
181
+ merged += `${entry.line}\n`;
182
+ }
183
+
184
+ return merged;
185
+ }
186
+
187
+ async function writeFileSafe(filePath, content, { force, results, merge }) {
188
+ const exists = await pathExists(filePath);
189
+
190
+ // Handle .env.example merging
191
+ if (exists && merge && filePath.endsWith('.env.example')) {
192
+ const existingContent = await fs.readFile(filePath, 'utf8');
193
+ const mergedContent = mergeEnvFiles(existingContent, content);
194
+
195
+ if (mergedContent !== existingContent) {
196
+ await fs.writeFile(filePath, mergedContent, 'utf8');
197
+ results.merged = results.merged || [];
198
+ results.merged.push(filePath);
199
+ } else {
200
+ results.skipped.push(filePath);
201
+ }
202
+ return;
203
+ }
204
+
205
+ // Never overwrite .env (user's actual config)
206
+ if (exists && filePath.endsWith('.env') && !filePath.endsWith('.env.example')) {
207
+ results.skipped.push(filePath);
208
+ return;
209
+ }
210
+
211
+ if (exists && !force) {
212
+ results.skipped.push(filePath);
213
+ return;
214
+ }
215
+ await ensureDir(path.dirname(filePath));
216
+ await fs.writeFile(filePath, content, 'utf8');
217
+ results.created.push(filePath);
218
+ }
219
+
220
+ async function detectPackageManager(startDir) {
221
+ let dir = startDir;
222
+ while (true) {
223
+ if (await pathExists(path.join(dir, 'bun.lockb'))) return 'bun';
224
+ if (await pathExists(path.join(dir, 'bun.lock'))) return 'bun';
225
+ if (await pathExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
226
+ if (await pathExists(path.join(dir, 'yarn.lock'))) return 'yarn';
227
+ if (await pathExists(path.join(dir, 'package-lock.json'))) return 'npm';
228
+ const parent = path.dirname(dir);
229
+ if (parent === dir) break;
230
+ dir = parent;
231
+ }
232
+ return 'npm';
233
+ }
234
+
235
+ function commandsFor(pm) {
236
+ switch (pm) {
237
+ case 'bun':
238
+ return { install: 'bun install', test: 'bun run test' };
239
+ case 'pnpm':
240
+ return { install: 'pnpm install', test: 'pnpm test' };
241
+ case 'yarn':
242
+ return { install: 'yarn install', test: 'yarn test' };
243
+ default:
244
+ return { install: 'npm install', test: 'npm test' };
245
+ }
246
+ }
247
+
248
+ function parseArgs(argv) {
249
+ const args = {
250
+ dir: 'katalyst-xspec',
251
+ force: false,
252
+ withSkills: undefined, // undefined = ask, true = install, false = skip
253
+ skillsAgents: null, // null = ask, array = use these agents
254
+ };
255
+ for (let i = 0; i < argv.length; i++) {
256
+ const arg = argv[i];
257
+ if (arg === '--dir' && argv[i + 1]) {
258
+ args.dir = argv[++i];
259
+ } else if (arg === '--force') {
260
+ args.force = true;
261
+ } else if (arg === '--with-skills') {
262
+ args.withSkills = true;
263
+ } else if (arg === '--no-skills') {
264
+ args.withSkills = false;
265
+ } else if (arg === '--skills-agents' && argv[i + 1]) {
266
+ const agentList = argv[++i].split(',').map(s => s.trim()).filter(Boolean);
267
+ const validAgents = agentList.filter(a => SKILL_AGENTS[a]);
268
+ args.skillsAgents = validAgents.length > 0 ? validAgents : null;
269
+ } else if (!arg.startsWith('-')) {
270
+ // Positional target directory: `katalyst-xspec init my-tests`
271
+ args.dir = arg;
272
+ }
273
+ }
274
+ return args;
275
+ }
276
+
277
+ function templates(packageName) {
278
+ const pkg = {
279
+ name: packageName,
280
+ private: true,
281
+ version: '0.1.0',
282
+ type: 'module',
283
+ scripts: {
284
+ gen: 'bddgen',
285
+ 'gen:stubs': 'katalyst-xspec stubs',
286
+ test: 'bddgen && playwright test',
287
+ 'check-updates': 'katalyst-xspec upgrade --check',
288
+ upgrade: 'katalyst-xspec upgrade',
289
+ 'upgrade:migrate': 'katalyst-xspec upgrade --migrate',
290
+ 'clean:gen': 'rm -rf .features-gen',
291
+ clean: 'rm -rf .features-gen node_modules test-results storage cucumber-report playwright-report'
292
+ },
293
+ devDependencies: {
294
+ '@esimplicitylabs/katalyst-xspec': '^0.6.0',
295
+ '@playwright/test': '^1.49.0',
296
+ 'playwright-bdd': '^9.1.0',
297
+ dotenv: '^16.1.4',
298
+ typescript: '^5.6.3'
299
+ },
300
+ engines: {
301
+ node: '>=20'
302
+ }
303
+ };
304
+
305
+ const tsconfig = {
306
+ compilerOptions: {
307
+ target: 'ES2021',
308
+ module: 'NodeNext',
309
+ moduleResolution: 'NodeNext',
310
+ strict: true,
311
+ types: ['node', '@playwright/test']
312
+ },
313
+ include: ['features/**/*.ts', 'playwright.config.ts']
314
+ };
315
+
316
+ const fixturesTs = `import {
317
+ createBddTest,
318
+ PlaywrightApiAdapter,
319
+ PlaywrightUiAdapter,
320
+ UniversalAuthAdapter,
321
+ DefaultCleanupAdapter,
322
+ TuiTesterAdapter,
323
+ } from '@esimplicitylabs/katalyst-xspec';
324
+
325
+ export const { test } = createBddTest({
326
+ createApi: ({ apiRequest }) => new PlaywrightApiAdapter(apiRequest),
327
+ createUi: ({ page }) => new PlaywrightUiAdapter(page),
328
+ createAuth: ({ api, ui }) => new UniversalAuthAdapter({ api, ui }),
329
+ createCleanup: () => new DefaultCleanupAdapter(),
330
+ // TUI testing (optional - requires tui-tester and tmux installed)
331
+ // Uncomment and configure for your CLI application:
332
+ // createTui: () => new TuiTesterAdapter({
333
+ // command: ['node', 'dist/cli.js'],
334
+ // size: { cols: 100, rows: 30 },
335
+ // debug: process.env.DEBUG === 'true',
336
+ // }),
337
+ });
338
+ `;
339
+
340
+ const stepsTs = `import { test } from './fixtures.js';
341
+ import {
342
+ registerApiSteps,
343
+ registerUiSteps,
344
+ registerSharedSteps,
345
+ registerHybridSuite,
346
+ registerTuiSteps,
347
+ } from '@esimplicitylabs/katalyst-xspec/steps';
348
+
349
+ registerApiSteps(test);
350
+ registerUiSteps(test);
351
+ registerSharedSteps(test);
352
+ registerHybridSuite(test);
353
+
354
+ // TUI steps (optional - requires tui-tester and tmux installed)
355
+ // Uncomment when you have TUI testing configured:
356
+ // registerTuiSteps(test);
357
+
358
+ export { test };
359
+ `;
360
+
361
+ const playwrightConfig = `import { defineConfig } from '@playwright/test';
362
+ import { defineBddProject, cucumberReporter } from 'playwright-bdd';
363
+ import { resolveWorkers } from '@esimplicitylabs/katalyst-xspec';
364
+ import dotenv from 'dotenv';
365
+ import fs from 'node:fs';
366
+ import path from 'node:path';
367
+ import { fileURLToPath } from 'node:url';
368
+
369
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
370
+ const localEnvPath = path.resolve(__dirname, '.env');
371
+ const rootEnvPath = path.resolve(process.cwd(), '.env');
372
+
373
+ if (fs.existsSync(localEnvPath)) {
374
+ dotenv.config({ path: localEnvPath });
375
+ } else if (fs.existsSync(rootEnvPath)) {
376
+ dotenv.config({ path: rootEnvPath });
377
+ } else {
378
+ dotenv.config();
379
+ }
380
+
381
+ const apiBdd = defineBddProject({
382
+ name: 'api',
383
+ features: 'features/api/**/*.feature',
384
+ steps: 'features/steps/**/*.ts',
385
+ tags: '@api',
386
+ });
387
+
388
+ const uiBdd = defineBddProject({
389
+ name: 'ui',
390
+ features: 'features/ui/**/*.feature',
391
+ steps: 'features/steps/**/*.ts',
392
+ tags: '@ui',
393
+ });
394
+
395
+ const hybridBdd = defineBddProject({
396
+ name: 'hybrid',
397
+ features: 'features/hybrid/**/*.feature',
398
+ steps: 'features/steps/**/*.ts',
399
+ tags: '@hybrid',
400
+ });
401
+
402
+ // TUI project (optional - uncomment when TUI testing is configured)
403
+ // const tuiBdd = defineBddProject({
404
+ // name: 'tui',
405
+ // features: 'features/tui/**/*.feature',
406
+ // steps: 'features/steps/**/*.ts',
407
+ // tags: '@tui',
408
+ // });
409
+
410
+ export default defineConfig({
411
+ workers: resolveWorkers(),
412
+ reporter: [
413
+ cucumberReporter('html', { outputFile: 'cucumber-report/index.html' }),
414
+ cucumberReporter('json', { outputFile: 'cucumber-report/report.json' }),
415
+ ],
416
+ // Add tuiBdd to this array when TUI testing is enabled
417
+ projects: [apiBdd, uiBdd, hybridBdd /* , tuiBdd */],
418
+ use: {
419
+ baseURL: process.env.BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3000',
420
+ headless: process.env.HEADLESS === 'false' ? false : true,
421
+ },
422
+ });
423
+ `;
424
+
425
+ const apiFeature = `Feature: API example
426
+ As an API consumer
427
+ I want to call the service
428
+ So that I can verify responses
429
+
430
+ @api
431
+ Scenario: GET health
432
+ When I GET "/health"
433
+ Then the response status should be 200
434
+ `;
435
+
436
+ const uiFeature = `Feature: UI example
437
+ As a user
438
+ I want to load the homepage
439
+ So that I can see content
440
+
441
+ @ui
442
+ Scenario: Visit homepage
443
+ Given I navigate to "/"
444
+ Then the URL should contain "/"
445
+ `;
446
+
447
+ const hybridFeature = `Feature: Hybrid example
448
+ As a tester
449
+ I want to mix API and UI steps
450
+ So that I can cover flows end-to-end
451
+
452
+ @hybrid
453
+ Scenario: API then UI
454
+ When I GET "/health"
455
+ Then the response status should be 200
456
+ Given I navigate to "/"
457
+ Then the URL should contain "/"
458
+ `;
459
+
460
+ const tuiFeature = `Feature: TUI example
461
+ As a CLI user
462
+ I want to interact with the terminal application
463
+ So that I can verify TUI functionality
464
+
465
+ @tui
466
+ Scenario: Start and verify TUI application
467
+ Given I start the TUI application
468
+ Then I should see "Welcome"
469
+ When I type "help"
470
+ And I press enter
471
+ Then I should see "Available commands"
472
+
473
+ @tui
474
+ Scenario: Navigate menu with keyboard
475
+ Given I start the TUI application
476
+ When I navigate down 2 times
477
+ And I press enter
478
+ Then I should see "Selected option"
479
+
480
+ @tui
481
+ Scenario: Fill form in TUI
482
+ Given I start the TUI application
483
+ When I enter "John Doe" in the "Name" field
484
+ And I press tab
485
+ And I enter "john@example.com" in the "Email" field
486
+ And I submit the form
487
+ Then I should see "Form submitted successfully"
488
+
489
+ @tui
490
+ Scenario: Verify screen snapshot
491
+ Given I start the TUI application
492
+ Then the screen should match snapshot "main-menu"
493
+ `;
494
+
495
+ const gitignore = `node_modules
496
+ .features-gen
497
+ playwright-report
498
+ test-results
499
+ cucumber-report
500
+ storage
501
+ .env
502
+ `;
503
+
504
+ const envExample = `# API defaults used by the auth and cleanup helpers
505
+ DEFAULT_ADMIN_USERNAME=admin@example.com
506
+ DEFAULT_ADMIN_PASSWORD=changeme
507
+ API_AUTH_LOGIN_PATH=/auth/login
508
+ API_BASE_URL=http://localhost:3000
509
+
510
+ # UI defaults
511
+ FRONTEND_URL=http://localhost:3000
512
+ HEADLESS=true
513
+
514
+ # Cleanup rules (JSON array)
515
+ # CLEANUP_RULES=[{"varMatch":"user","path":"/api/users/{id}"}]
516
+
517
+ # TUI testing (optional)
518
+ # Set DEBUG=true to see TUI tester output
519
+ DEBUG=false
520
+
521
+ # Worker configuration
522
+ # Set to a number for explicit worker count, or "auto" to let Playwright decide
523
+ # In CI, defaults to 1 for stability unless explicitly overridden
524
+ # WORKERS=auto
525
+ `;
526
+
527
+ const readme = `# katalyst-xspec
528
+
529
+ Generated Playwright + BDD test package powered by @esimplicitylabs/katalyst-xspec.
530
+
531
+ ## Install
532
+ Install deps in this folder (see commands printed by the generator).
533
+
534
+ ## Run
535
+ - Generate tests: \
536
+ \`npm run gen\`
537
+ - Run tests: \
538
+ \`npm test\`
539
+
540
+ ## Structure
541
+ - \`features/api|ui|hybrid|tui\`: feature files
542
+ - \`features/steps/steps.ts\`: registers steps from @esimplicitylabs/katalyst-xspec
543
+ - \`features/steps/fixtures.ts\`: creates the Playwright-BDD test with adapters
544
+ - \`playwright.config.ts\`: BDD-aware Playwright config with reporters
545
+
546
+ ## Notes
547
+ - Edit \`playwright.config.ts\` projects/tags to match your repo.
548
+ - Keep @playwright/test and playwright-bdd versions aligned with @esimplicitylabs/katalyst-xspec peer ranges.
549
+
550
+ ## TUI Testing (Optional)
551
+ To enable terminal user interface testing:
552
+
553
+ 1. Install tmux (required by tui-tester):
554
+ \`\`\`bash
555
+ # macOS
556
+ brew install tmux
557
+
558
+ # Ubuntu/Debian
559
+ apt-get install tmux
560
+ \`\`\`
561
+
562
+ 2. Install tui-tester:
563
+ \`\`\`bash
564
+ npm install tui-tester
565
+ \`\`\`
566
+
567
+ 3. Uncomment TUI configuration in:
568
+ - \`features/steps/fixtures.ts\`: Configure TuiTesterAdapter with your CLI command
569
+ - \`features/steps/steps.ts\`: Uncomment registerTuiSteps(test)
570
+ - \`playwright.config.ts\`: Uncomment tuiBdd project and add to projects array
571
+
572
+ 4. Write @tui tagged feature files in \`features/tui/\`
573
+ `;
574
+
575
+ return {
576
+ 'package.json': JSON.stringify(pkg, null, 2) + '\n',
577
+ 'tsconfig.json': JSON.stringify(tsconfig, null, 2) + '\n',
578
+ 'playwright.config.ts': playwrightConfig,
579
+ 'features/steps/fixtures.ts': fixturesTs,
580
+ 'features/steps/steps.ts': stepsTs,
581
+ 'features/api/00_api_examples.feature': apiFeature,
582
+ 'features/ui/00_ui_examples.feature': uiFeature,
583
+ 'features/hybrid/00_hybrid_examples.feature': hybridFeature,
584
+ 'features/tui/00_tui_examples.feature': tuiFeature,
585
+ '.gitignore': gitignore,
586
+ '.env.example': envExample,
587
+ 'README.md': readme,
588
+ };
589
+ }
590
+
591
+ async function main() {
592
+ const args = parseArgs(process.argv.slice(2));
593
+ const targetDir = path.resolve(process.cwd(), args.dir);
594
+ const detectedPm = await detectPackageManager(process.cwd());
595
+ const pm = commandsFor(detectedPm);
596
+ const files = templates('katalyst-xspec');
597
+
598
+ console.log(`Detected package manager: ${detectedPm}`);
599
+
600
+ const results = { created: [], skipped: [], merged: [], skills: [] };
601
+ await ensureDir(targetDir);
602
+
603
+ for (const [rel, content] of Object.entries(files)) {
604
+ const filePath = path.join(targetDir, rel);
605
+ // Enable merging for .env.example files
606
+ const merge = rel.endsWith('.env.example');
607
+ await writeFileSafe(filePath, content, { force: args.force, results, merge });
608
+ }
609
+
610
+ console.log(`\nScaffold complete at ${targetDir}`);
611
+ if (results.created.length) {
612
+ console.log('Created files:');
613
+ results.created.forEach((f) => console.log(` + ${path.relative(process.cwd(), f)}`));
614
+ }
615
+ if (results.merged && results.merged.length) {
616
+ console.log('Merged files (new variables added):');
617
+ results.merged.forEach((f) => console.log(` M ${path.relative(process.cwd(), f)}`));
618
+ }
619
+ if (results.skipped.length) {
620
+ console.log('Skipped existing files (use --force to overwrite):');
621
+ results.skipped.forEach((f) => console.log(` ~ ${path.relative(process.cwd(), f)}`));
622
+ }
623
+
624
+ // Skills installation
625
+ let installSkills = args.withSkills;
626
+ if (installSkills === undefined) {
627
+ // Interactive mode - ask user
628
+ installSkills = await promptYesNo('\nWould you like to install Katalyst BDD Agent Skills?');
629
+ }
630
+
631
+ if (installSkills) {
632
+ let agents = args.skillsAgents;
633
+ if (!agents) {
634
+ // Interactive mode - ask which agents
635
+ const agentOptions = Object.entries(SKILL_AGENT_LABELS).map(([key, label]) => ({ key, label }));
636
+ agents = await promptMultiSelect('Select agents to install skills to:', agentOptions);
637
+ }
638
+
639
+ console.log('\nInstalling skills...');
640
+ await copySkillsToAgents(targetDir, agents, results);
641
+
642
+ if (results.skills.length) {
643
+ console.log('Installed skills to:');
644
+ // Group by agent directory
645
+ const byAgent = {};
646
+ for (const skillPath of results.skills) {
647
+ const parts = skillPath.split(path.sep);
648
+ const agentDir = parts.slice(0, -1).join(path.sep);
649
+ if (!byAgent[agentDir]) byAgent[agentDir] = [];
650
+ byAgent[agentDir].push(parts[parts.length - 1]);
651
+ }
652
+ for (const [agentDir, skillNames] of Object.entries(byAgent)) {
653
+ console.log(` ${agentDir}/`);
654
+ skillNames.forEach(name => console.log(` + ${name}/`));
655
+ }
656
+ }
657
+ }
658
+
659
+ console.log('\nNext steps:');
660
+ console.log(` 1) cd ${path.relative(process.cwd(), targetDir) || '.'}`);
661
+ console.log(` 2) ${pm.install}`);
662
+ console.log(` 3) ${pm.test}`);
663
+
664
+ if (results.skills.length) {
665
+ console.log('\nSkills installed! Your AI agent can now help you:');
666
+ console.log(' - Create BDD tests with katalyst-bdd-create-test');
667
+ console.log(' - Look up step definitions with katalyst-bdd-step-reference');
668
+ console.log(' - Debug issues with katalyst-bdd-troubleshooting');
669
+ }
670
+ }
671
+
672
+ if (require.main === module) {
673
+ main().catch((err) => {
674
+ console.error(err);
675
+ process.exit(1);
676
+ });
677
+ }
678
+
679
+ module.exports = { main };