real-prototypes-skill 0.1.0 → 0.1.2

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.
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Prototype Detection Module
5
+ *
6
+ * Detects existing prototypes in a project directory to prevent creating
7
+ * new projects when one already exists. Also maps captured pages to
8
+ * existing prototype files.
9
+ *
10
+ * Usage:
11
+ * node detect-prototype.js --project <name>
12
+ * node detect-prototype.js --path /path/to/project
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // Framework detection patterns
19
+ const FRAMEWORK_PATTERNS = {
20
+ 'next.js-app-router': {
21
+ markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
22
+ structure: ['app/', 'src/app/'],
23
+ pagePattern: /page\.(tsx?|jsx?)$/,
24
+ layoutPattern: /layout\.(tsx?|jsx?)$/
25
+ },
26
+ 'next.js-pages-router': {
27
+ markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
28
+ structure: ['pages/', 'src/pages/'],
29
+ pagePattern: /\.(tsx?|jsx?)$/
30
+ },
31
+ 'react-vite': {
32
+ markers: ['vite.config.js', 'vite.config.ts'],
33
+ structure: ['src/'],
34
+ pagePattern: /\.(tsx?|jsx?)$/
35
+ },
36
+ 'react-cra': {
37
+ markers: ['react-scripts'],
38
+ structure: ['src/'],
39
+ pagePattern: /\.(tsx?|jsx?)$/
40
+ },
41
+ 'vue': {
42
+ markers: ['vue.config.js', 'vite.config.js'],
43
+ structure: ['src/'],
44
+ pagePattern: /\.vue$/
45
+ },
46
+ 'angular': {
47
+ markers: ['angular.json'],
48
+ structure: ['src/app/'],
49
+ pagePattern: /\.component\.ts$/
50
+ },
51
+ 'svelte': {
52
+ markers: ['svelte.config.js'],
53
+ structure: ['src/'],
54
+ pagePattern: /\.svelte$/
55
+ }
56
+ };
57
+
58
+ // Styling approach detection
59
+ const STYLING_PATTERNS = {
60
+ 'tailwind': {
61
+ markers: ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs'],
62
+ imports: ['tailwindcss', 'tailwind']
63
+ },
64
+ 'css-modules': {
65
+ patterns: [/\.module\.css$/, /\.module\.scss$/]
66
+ },
67
+ 'styled-components': {
68
+ imports: ['styled-components']
69
+ },
70
+ 'emotion': {
71
+ imports: ['@emotion/react', '@emotion/styled']
72
+ },
73
+ 'sass': {
74
+ markers: [],
75
+ patterns: [/\.scss$/, /\.sass$/]
76
+ },
77
+ 'inline-styles': {
78
+ patterns: [/style\s*=\s*\{\{/]
79
+ }
80
+ };
81
+
82
+ class PrototypeDetector {
83
+ constructor(projectPath) {
84
+ this.projectPath = path.resolve(projectPath);
85
+ this.result = {
86
+ exists: false,
87
+ framework: null,
88
+ frameworkVersion: null,
89
+ styling: [],
90
+ projectRoot: null,
91
+ srcRoot: null,
92
+ pages: [],
93
+ components: [],
94
+ mappedPages: {},
95
+ packageJson: null
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Run full detection
101
+ */
102
+ detect() {
103
+ // Step 1: Find package.json
104
+ this.findPackageJson();
105
+
106
+ if (!this.result.exists) {
107
+ return this.result;
108
+ }
109
+
110
+ // Step 2: Detect framework
111
+ this.detectFramework();
112
+
113
+ // Step 3: Find source root
114
+ this.findSourceRoot();
115
+
116
+ // Step 4: Detect styling approach
117
+ this.detectStyling();
118
+
119
+ // Step 5: Find existing pages
120
+ this.findPages();
121
+
122
+ // Step 6: Find existing components
123
+ this.findComponents();
124
+
125
+ return this.result;
126
+ }
127
+
128
+ /**
129
+ * Find package.json in project directory
130
+ */
131
+ findPackageJson() {
132
+ const possiblePaths = [
133
+ path.join(this.projectPath, 'package.json'),
134
+ path.join(this.projectPath, 'prototype', 'package.json'),
135
+ path.join(this.projectPath, 'src', 'package.json')
136
+ ];
137
+
138
+ for (const pkgPath of possiblePaths) {
139
+ if (fs.existsSync(pkgPath)) {
140
+ try {
141
+ this.result.packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
142
+ this.result.projectRoot = path.dirname(pkgPath);
143
+ this.result.exists = true;
144
+ return;
145
+ } catch (e) {
146
+ // Continue to next path
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Detect framework from markers and dependencies
154
+ */
155
+ detectFramework() {
156
+ if (!this.result.packageJson) return;
157
+
158
+ const deps = {
159
+ ...this.result.packageJson.dependencies,
160
+ ...this.result.packageJson.devDependencies
161
+ };
162
+
163
+ // Check for Next.js
164
+ if (deps['next']) {
165
+ const version = deps['next'].replace(/[\^~]/, '');
166
+ this.result.frameworkVersion = version;
167
+
168
+ // Check if using App Router or Pages Router
169
+ const appDir = this.checkDir('app') || this.checkDir('src/app');
170
+ const pagesDir = this.checkDir('pages') || this.checkDir('src/pages');
171
+
172
+ if (appDir) {
173
+ this.result.framework = 'next.js-app-router';
174
+ } else if (pagesDir) {
175
+ this.result.framework = 'next.js-pages-router';
176
+ } else {
177
+ // Default to app router for Next.js 13+
178
+ const majorVersion = parseInt(version.split('.')[0], 10);
179
+ this.result.framework = majorVersion >= 13 ? 'next.js-app-router' : 'next.js-pages-router';
180
+ }
181
+ return;
182
+ }
183
+
184
+ // Check for Vite + React
185
+ if (deps['vite'] && deps['react']) {
186
+ this.result.framework = 'react-vite';
187
+ this.result.frameworkVersion = deps['vite'].replace(/[\^~]/, '');
188
+ return;
189
+ }
190
+
191
+ // Check for CRA
192
+ if (deps['react-scripts']) {
193
+ this.result.framework = 'react-cra';
194
+ this.result.frameworkVersion = deps['react-scripts'].replace(/[\^~]/, '');
195
+ return;
196
+ }
197
+
198
+ // Check for Vue
199
+ if (deps['vue']) {
200
+ this.result.framework = 'vue';
201
+ this.result.frameworkVersion = deps['vue'].replace(/[\^~]/, '');
202
+ return;
203
+ }
204
+
205
+ // Check for Angular
206
+ if (deps['@angular/core']) {
207
+ this.result.framework = 'angular';
208
+ this.result.frameworkVersion = deps['@angular/core'].replace(/[\^~]/, '');
209
+ return;
210
+ }
211
+
212
+ // Check for Svelte
213
+ if (deps['svelte']) {
214
+ this.result.framework = 'svelte';
215
+ this.result.frameworkVersion = deps['svelte'].replace(/[\^~]/, '');
216
+ return;
217
+ }
218
+
219
+ // Default to React if react is present
220
+ if (deps['react']) {
221
+ this.result.framework = 'react';
222
+ this.result.frameworkVersion = deps['react'].replace(/[\^~]/, '');
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Check if directory exists relative to project root
228
+ */
229
+ checkDir(relativePath) {
230
+ const fullPath = path.join(this.result.projectRoot, relativePath);
231
+ return fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory() ? fullPath : null;
232
+ }
233
+
234
+ /**
235
+ * Find the source root directory
236
+ */
237
+ findSourceRoot() {
238
+ const candidates = ['src', 'app', 'pages', 'lib'];
239
+
240
+ for (const candidate of candidates) {
241
+ const candidatePath = path.join(this.result.projectRoot, candidate);
242
+ if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
243
+ this.result.srcRoot = candidatePath;
244
+ return;
245
+ }
246
+ }
247
+
248
+ // Default to project root
249
+ this.result.srcRoot = this.result.projectRoot;
250
+ }
251
+
252
+ /**
253
+ * Detect styling approach used in the project
254
+ */
255
+ detectStyling() {
256
+ if (!this.result.packageJson) return;
257
+
258
+ const deps = {
259
+ ...this.result.packageJson.dependencies,
260
+ ...this.result.packageJson.devDependencies
261
+ };
262
+
263
+ // Check for Tailwind
264
+ if (deps['tailwindcss'] || this.fileExists('tailwind.config.js') ||
265
+ this.fileExists('tailwind.config.ts') || this.fileExists('tailwind.config.mjs')) {
266
+ this.result.styling.push('tailwind');
267
+ }
268
+
269
+ // Check for styled-components
270
+ if (deps['styled-components']) {
271
+ this.result.styling.push('styled-components');
272
+ }
273
+
274
+ // Check for Emotion
275
+ if (deps['@emotion/react'] || deps['@emotion/styled']) {
276
+ this.result.styling.push('emotion');
277
+ }
278
+
279
+ // Check for Sass
280
+ if (deps['sass'] || deps['node-sass']) {
281
+ this.result.styling.push('sass');
282
+ }
283
+
284
+ // Scan for CSS modules
285
+ if (this.result.srcRoot) {
286
+ const hasModules = this.scanForPattern(this.result.srcRoot, /\.module\.(css|scss)$/);
287
+ if (hasModules) {
288
+ this.result.styling.push('css-modules');
289
+ }
290
+ }
291
+
292
+ // Default to inline styles if nothing detected
293
+ if (this.result.styling.length === 0) {
294
+ this.result.styling.push('inline-styles');
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Check if file exists relative to project root
300
+ */
301
+ fileExists(relativePath) {
302
+ return fs.existsSync(path.join(this.result.projectRoot, relativePath));
303
+ }
304
+
305
+ /**
306
+ * Scan directory for files matching pattern
307
+ */
308
+ scanForPattern(dir, pattern, maxDepth = 3, currentDepth = 0) {
309
+ if (currentDepth > maxDepth) return false;
310
+
311
+ try {
312
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
313
+
314
+ for (const entry of entries) {
315
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
316
+
317
+ const fullPath = path.join(dir, entry.name);
318
+
319
+ if (entry.isFile() && pattern.test(entry.name)) {
320
+ return true;
321
+ }
322
+
323
+ if (entry.isDirectory()) {
324
+ if (this.scanForPattern(fullPath, pattern, maxDepth, currentDepth + 1)) {
325
+ return true;
326
+ }
327
+ }
328
+ }
329
+ } catch (e) {
330
+ // Ignore errors
331
+ }
332
+
333
+ return false;
334
+ }
335
+
336
+ /**
337
+ * Find existing page files
338
+ */
339
+ findPages() {
340
+ const framework = this.result.framework;
341
+
342
+ if (!framework || !this.result.projectRoot) return;
343
+
344
+ let pageRoots = [];
345
+ let pagePattern = /\.(tsx?|jsx?)$/;
346
+
347
+ if (framework === 'next.js-app-router') {
348
+ pageRoots = [
349
+ path.join(this.result.projectRoot, 'app'),
350
+ path.join(this.result.projectRoot, 'src', 'app')
351
+ ];
352
+ pagePattern = /page\.(tsx?|jsx?)$/;
353
+ } else if (framework === 'next.js-pages-router') {
354
+ pageRoots = [
355
+ path.join(this.result.projectRoot, 'pages'),
356
+ path.join(this.result.projectRoot, 'src', 'pages')
357
+ ];
358
+ } else if (framework === 'vue') {
359
+ pageRoots = [
360
+ path.join(this.result.projectRoot, 'src', 'views'),
361
+ path.join(this.result.projectRoot, 'src', 'pages')
362
+ ];
363
+ pagePattern = /\.vue$/;
364
+ }
365
+
366
+ for (const pageRoot of pageRoots) {
367
+ if (fs.existsSync(pageRoot)) {
368
+ this.scanPages(pageRoot, pageRoot, pagePattern);
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Recursively scan for page files
375
+ */
376
+ scanPages(dir, rootDir, pattern, currentPath = '') {
377
+ try {
378
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
379
+
380
+ for (const entry of entries) {
381
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
382
+
383
+ const fullPath = path.join(dir, entry.name);
384
+ const relativePath = path.join(currentPath, entry.name);
385
+
386
+ if (entry.isFile() && pattern.test(entry.name)) {
387
+ // Convert file path to route
388
+ const route = this.filePathToRoute(relativePath, pattern);
389
+
390
+ this.result.pages.push({
391
+ file: fullPath,
392
+ relativePath: relativePath,
393
+ route: route,
394
+ name: this.routeToName(route)
395
+ });
396
+ }
397
+
398
+ if (entry.isDirectory()) {
399
+ this.scanPages(fullPath, rootDir, pattern, relativePath);
400
+ }
401
+ }
402
+ } catch (e) {
403
+ // Ignore errors
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Convert file path to route
409
+ */
410
+ filePathToRoute(filePath, pattern) {
411
+ let route = filePath
412
+ .replace(pattern, '')
413
+ .replace(/\\/g, '/')
414
+ .replace(/\/index$/, '')
415
+ .replace(/\/page$/, '');
416
+
417
+ // Handle dynamic routes [param]
418
+ route = route.replace(/\[([^\]]+)\]/g, ':$1');
419
+
420
+ return '/' + route || '/';
421
+ }
422
+
423
+ /**
424
+ * Convert route to human-readable name
425
+ */
426
+ routeToName(route) {
427
+ return route
428
+ .split('/')
429
+ .filter(Boolean)
430
+ .map(part => part.replace(/^:/, ''))
431
+ .join('-') || 'home';
432
+ }
433
+
434
+ /**
435
+ * Find existing component files
436
+ */
437
+ findComponents() {
438
+ const componentDirs = [
439
+ path.join(this.result.projectRoot, 'components'),
440
+ path.join(this.result.projectRoot, 'src', 'components'),
441
+ path.join(this.result.srcRoot || this.result.projectRoot, 'components')
442
+ ];
443
+
444
+ for (const dir of componentDirs) {
445
+ if (fs.existsSync(dir)) {
446
+ this.scanComponents(dir, dir);
447
+ }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Recursively scan for component files
453
+ */
454
+ scanComponents(dir, rootDir) {
455
+ try {
456
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
457
+
458
+ for (const entry of entries) {
459
+ if (entry.name.startsWith('.')) continue;
460
+
461
+ const fullPath = path.join(dir, entry.name);
462
+
463
+ if (entry.isFile() && /\.(tsx?|jsx?|vue|svelte)$/.test(entry.name)) {
464
+ const relativePath = path.relative(rootDir, fullPath);
465
+ const name = entry.name.replace(/\.(tsx?|jsx?|vue|svelte)$/, '');
466
+
467
+ this.result.components.push({
468
+ file: fullPath,
469
+ relativePath: relativePath,
470
+ name: name
471
+ });
472
+ }
473
+
474
+ if (entry.isDirectory()) {
475
+ this.scanComponents(fullPath, rootDir);
476
+ }
477
+ }
478
+ } catch (e) {
479
+ // Ignore errors
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Map captured pages from manifest to existing prototype files
485
+ */
486
+ mapCapturedPages(manifest) {
487
+ if (!manifest || !manifest.pages || !this.result.pages.length) {
488
+ return this.result.mappedPages;
489
+ }
490
+
491
+ for (const capturedPage of manifest.pages) {
492
+ const capturedName = capturedPage.name.toLowerCase()
493
+ .replace(/[_-]/g, '')
494
+ .replace(/\s+/g, '');
495
+
496
+ // Try to find matching prototype page
497
+ for (const prototypePage of this.result.pages) {
498
+ const protoName = prototypePage.name.toLowerCase()
499
+ .replace(/[_-]/g, '')
500
+ .replace(/\s+/g, '');
501
+
502
+ if (capturedName.includes(protoName) || protoName.includes(capturedName)) {
503
+ this.result.mappedPages[capturedPage.name] = {
504
+ captured: capturedPage,
505
+ prototype: prototypePage,
506
+ similarity: this.calculateSimilarity(capturedName, protoName)
507
+ };
508
+ break;
509
+ }
510
+ }
511
+ }
512
+
513
+ return this.result.mappedPages;
514
+ }
515
+
516
+ /**
517
+ * Calculate string similarity (simple Jaccard)
518
+ */
519
+ calculateSimilarity(str1, str2) {
520
+ const set1 = new Set(str1.split(''));
521
+ const set2 = new Set(str2.split(''));
522
+ const intersection = new Set([...set1].filter(x => set2.has(x)));
523
+ const union = new Set([...set1, ...set2]);
524
+ return intersection.size / union.size;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Detect existing prototype in project directory
530
+ */
531
+ function detectPrototype(projectPath) {
532
+ const detector = new PrototypeDetector(projectPath);
533
+ return detector.detect();
534
+ }
535
+
536
+ /**
537
+ * Map captured pages to prototype files
538
+ */
539
+ function mapPages(projectPath, manifest) {
540
+ const detector = new PrototypeDetector(projectPath);
541
+ detector.detect();
542
+ return detector.mapCapturedPages(manifest);
543
+ }
544
+
545
+ /**
546
+ * Format detection result for CLI output
547
+ */
548
+ function formatResult(result) {
549
+ const lines = [];
550
+
551
+ if (!result.exists) {
552
+ lines.push('\x1b[33m⚠ No existing prototype found\x1b[0m');
553
+ return lines.join('\n');
554
+ }
555
+
556
+ lines.push('\x1b[32m✓ Existing prototype found\x1b[0m');
557
+ lines.push(` Framework: ${result.framework || 'Unknown'}${result.frameworkVersion ? ` v${result.frameworkVersion}` : ''}`);
558
+ lines.push(` Project root: ${result.projectRoot}`);
559
+ lines.push(` Styling: ${result.styling.join(', ') || 'Unknown'}`);
560
+
561
+ if (result.pages.length > 0) {
562
+ lines.push(`\n \x1b[1mExisting Pages (${result.pages.length}):\x1b[0m`);
563
+ for (const page of result.pages.slice(0, 10)) {
564
+ lines.push(` ${page.route} → ${path.basename(page.file)}`);
565
+ }
566
+ if (result.pages.length > 10) {
567
+ lines.push(` ... and ${result.pages.length - 10} more`);
568
+ }
569
+ }
570
+
571
+ if (result.components.length > 0) {
572
+ lines.push(`\n \x1b[1mExisting Components (${result.components.length}):\x1b[0m`);
573
+ for (const comp of result.components.slice(0, 10)) {
574
+ lines.push(` ${comp.name}`);
575
+ }
576
+ if (result.components.length > 10) {
577
+ lines.push(` ... and ${result.components.length - 10} more`);
578
+ }
579
+ }
580
+
581
+ if (Object.keys(result.mappedPages).length > 0) {
582
+ lines.push(`\n \x1b[1mMapped Pages:\x1b[0m`);
583
+ for (const [captured, mapping] of Object.entries(result.mappedPages)) {
584
+ lines.push(` ${captured} → ${mapping.prototype.file}`);
585
+ }
586
+ }
587
+
588
+ return lines.join('\n');
589
+ }
590
+
591
+ // CLI execution
592
+ if (require.main === module) {
593
+ const args = process.argv.slice(2);
594
+ let projectPath = '.';
595
+ let manifestPath = null;
596
+
597
+ for (let i = 0; i < args.length; i++) {
598
+ if (args[i] === '--path' || args[i] === '-p') {
599
+ projectPath = args[++i];
600
+ } else if (args[i] === '--project') {
601
+ const projectName = args[++i];
602
+ const SKILL_DIR = __dirname;
603
+ const PROJECTS_DIR = path.resolve(SKILL_DIR, '../../../../projects');
604
+ projectPath = path.join(PROJECTS_DIR, projectName, 'prototype');
605
+ } else if (args[i] === '--manifest' || args[i] === '-m') {
606
+ manifestPath = args[++i];
607
+ } else if (args[i] === '--help' || args[i] === '-h') {
608
+ console.log(`
609
+ Usage: node detect-prototype.js [options]
610
+
611
+ Options:
612
+ --path, -p <path> Path to project directory
613
+ --project <name> Project name (looks in projects/<name>/prototype)
614
+ --manifest, -m <path> Path to manifest.json for page mapping
615
+ --help, -h Show this help
616
+
617
+ Examples:
618
+ node detect-prototype.js --project my-app
619
+ node detect-prototype.js --path ./projects/my-app/prototype
620
+ node detect-prototype.js --path ./prototype --manifest ./references/manifest.json
621
+ `);
622
+ process.exit(0);
623
+ }
624
+ }
625
+
626
+ const result = detectPrototype(projectPath);
627
+
628
+ // Load manifest if provided
629
+ if (manifestPath && fs.existsSync(manifestPath)) {
630
+ try {
631
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
632
+ const detector = new PrototypeDetector(projectPath);
633
+ detector.detect();
634
+ detector.mapCapturedPages(manifest);
635
+ result.mappedPages = detector.result.mappedPages;
636
+ } catch (e) {
637
+ console.error(`Failed to load manifest: ${e.message}`);
638
+ }
639
+ }
640
+
641
+ console.log(formatResult(result));
642
+
643
+ // Exit with code 0 if prototype exists, 1 if not
644
+ process.exit(result.exists ? 0 : 1);
645
+ }
646
+
647
+ module.exports = {
648
+ PrototypeDetector,
649
+ detectPrototype,
650
+ mapPages,
651
+ formatResult
652
+ };