@gettrace/cli 1.1.6 → 1.2.7

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/dist/index.js CHANGED
@@ -5,13 +5,88 @@
5
5
  // ============================================
6
6
  import { program } from 'commander';
7
7
  import chalk from 'chalk';
8
- import { WebSocketServer } from 'ws';
8
+ import { WebSocketServer, WebSocket } from 'ws';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
- import { exec } from 'child_process';
11
+ import { exec, spawn } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  const execAsync = promisify(exec);
14
- const VERSION = '1.1.6';
14
+ // ============================================
15
+ // TERMINAL BUFFER
16
+ // Rolling 500-line ring buffer of terminal output.
17
+ // Shared across all WebSocket clients so every
18
+ // connected extension gets the same live stream.
19
+ // ============================================
20
+ class TerminalBuffer {
21
+ lines = [];
22
+ MAX_LINES = 500;
23
+ push(chunk) {
24
+ // Strip ANSI escape codes so agents get clean text
25
+ const clean = chunk.replace(/\x1B\[[0-9;]*[mGKHFABCDEJsu]/g, '');
26
+ const newLines = clean.split('\n');
27
+ for (const line of newLines) {
28
+ if (line.trim() === '')
29
+ continue;
30
+ this.lines.push(line);
31
+ if (this.lines.length > this.MAX_LINES) {
32
+ this.lines.shift();
33
+ }
34
+ }
35
+ }
36
+ getLast(n = 100) {
37
+ return this.lines.slice(-n).join('\n');
38
+ }
39
+ clear() {
40
+ this.lines = [];
41
+ }
42
+ get length() {
43
+ return this.lines.length;
44
+ }
45
+ }
46
+ // Singleton — shared across all WS connections for `trace dev`
47
+ const globalTerminalBuffer = new TerminalBuffer();
48
+ let devProcess = null;
49
+ // ============================================
50
+ // DEV COMMAND DETECTION
51
+ // Reads package.json to find the right script
52
+ // and detects package manager from lockfiles.
53
+ // ============================================
54
+ function detectDevCommand(projectPath, override) {
55
+ // 1. If user passed an explicit command, use it directly
56
+ if (override) {
57
+ return { command: override, pm: 'custom', script: override };
58
+ }
59
+ // 2. Detect package manager from lockfiles
60
+ let pm = 'npm';
61
+ if (fs.existsSync(path.join(projectPath, 'bun.lockb')))
62
+ pm = 'bun';
63
+ else if (fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml')))
64
+ pm = 'pnpm';
65
+ else if (fs.existsSync(path.join(projectPath, 'yarn.lock')))
66
+ pm = 'yarn';
67
+ // 3. Read package.json scripts
68
+ const pkgPath = path.join(projectPath, 'package.json');
69
+ let scripts = {};
70
+ if (fs.existsSync(pkgPath)) {
71
+ try {
72
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
73
+ scripts = pkg.scripts || {};
74
+ }
75
+ catch (e) { /* malformed package.json */ }
76
+ }
77
+ // 4. Priority order: dev > start > serve > preview
78
+ const candidates = ['dev', 'start', 'serve', 'preview'];
79
+ const script = candidates.find(c => !!scripts[c]) || 'dev';
80
+ // 5. Build the final command
81
+ const runCmd = {
82
+ npm: `npm run ${script}`,
83
+ pnpm: `pnpm run ${script}`,
84
+ yarn: `yarn ${script}`,
85
+ bun: `bun run ${script}`,
86
+ };
87
+ return { command: runCmd[pm] ?? `npm run ${script}`, pm, script };
88
+ }
89
+ const VERSION = '1.2.7';
15
90
  /** Levenshtein distance for fuzzy block matching */
16
91
  function levenshtein(a, b) {
17
92
  if (a === '' || b === '')
@@ -373,919 +448,1104 @@ async function autoFormat(filePath, projectPath) {
373
448
  return null;
374
449
  }
375
450
  program
376
- .name('trace-connect')
377
- .description('IDE Bridge for Trace Extension allowing secure file system access')
451
+ .name('trace')
452
+ .description('Trace IDE Bridge connect your codebase and dev server to the Trace extension')
378
453
  .version(VERSION);
454
+ // ============================================
455
+ // COMMAND: trace dev
456
+ // Starts your dev server AND the IDE bridge in
457
+ // one command. Terminal output is streamed to
458
+ // the extension so Trace agents have live context.
459
+ // ============================================
379
460
  program
380
- .command('connect', { isDefault: true })
381
- .alias('c')
382
- .description('Start IDE bridge for Chrome extension (enables source code access)')
383
- .option('-p, --port <port>', 'WebSocket port', '8765')
384
- .action(async (options) => {
461
+ .command('dev')
462
+ .description('Start dev server + IDE bridge together (recommended)')
463
+ .argument('[command]', 'Override the dev command (e.g. "npm run start:staging")')
464
+ .option('-p, --port <port>', 'WebSocket port for IDE bridge', '8765')
465
+ .action(async (commandOverride, options) => {
385
466
  const port = parseInt(options.port);
386
467
  const projectPath = process.cwd();
468
+ const { command, pm, script } = detectDevCommand(projectPath, commandOverride);
469
+ console.log();
470
+ console.log(chalk.bold.cyan('⚡ Trace Dev'));
471
+ console.log(chalk.gray('─'.repeat(55)));
472
+ console.log();
473
+ console.log(`📁 Project: ${chalk.green(projectPath)}`);
474
+ console.log(`📦 Package Mgr: ${chalk.yellow(pm)}`);
475
+ console.log(`🚀 Dev Command: ${chalk.cyan(command)}`);
476
+ console.log(`🌐 Bridge Port: ${chalk.cyan(port)}`);
387
477
  console.log();
388
- console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
389
478
  console.log(chalk.gray('─'.repeat(55)));
390
479
  console.log();
391
- console.log(`📁 Project: ${chalk.green(projectPath)}`);
392
- console.log(`🌐 Port: ${chalk.cyan(port)}`);
480
+ // Track all connected WebSocket clients so we can push STREAM_CHUNK events
481
+ const connectedClients = new Set();
482
+ const broadcast = (payload) => {
483
+ const msg = JSON.stringify(payload);
484
+ for (const client of connectedClients) {
485
+ if (client.readyState === WebSocket.OPEN) {
486
+ client.send(msg);
487
+ }
488
+ }
489
+ };
490
+ // ── Start the dev server ──────────────────────────────────
491
+ console.log(chalk.dim('Starting dev server...'));
393
492
  console.log();
394
- // Read package.json for project info
493
+ devProcess = spawn(command, [], {
494
+ cwd: projectPath,
495
+ shell: true,
496
+ env: { ...process.env, FORCE_COLOR: '1' },
497
+ });
498
+ devProcess.stdout?.on('data', (chunk) => {
499
+ const text = chunk.toString();
500
+ // Print to terminal so the developer still sees their logs
501
+ process.stdout.write(text);
502
+ // Store in rolling buffer (ANSI stripped)
503
+ globalTerminalBuffer.push(text);
504
+ // Push raw chunk to extension for live terminal panel
505
+ broadcast({ type: 'STREAM_CHUNK', stream: 'stdout', chunk: text });
506
+ });
507
+ devProcess.stderr?.on('data', (chunk) => {
508
+ const text = chunk.toString();
509
+ process.stderr.write(text);
510
+ globalTerminalBuffer.push(text);
511
+ broadcast({ type: 'STREAM_CHUNK', stream: 'stderr', chunk: text });
512
+ });
513
+ devProcess.on('close', (code) => {
514
+ const msg = `\n[Trace] Dev server exited with code ${code}\n`;
515
+ process.stdout.write(chalk.yellow(msg));
516
+ globalTerminalBuffer.push(msg);
517
+ broadcast({ type: 'STREAM_END', exitCode: code });
518
+ devProcess = null;
519
+ });
520
+ devProcess.on('error', (err) => {
521
+ const msg = `[Trace] Failed to start dev server: ${err.message}\n`;
522
+ process.stderr.write(chalk.red(msg));
523
+ console.error(chalk.red('\n✗ Could not start dev server.'));
524
+ console.error(chalk.dim(` Command: ${command}`));
525
+ console.error(chalk.dim(` Make sure the script exists in your package.json`));
526
+ });
527
+ // ── Start the WebSocket IDE bridge ────────────────────────
528
+ const wss = new WebSocketServer({ port });
529
+ let clientCount = 0;
530
+ wss.on('listening', () => {
531
+ console.log();
532
+ console.log(chalk.gray('─'.repeat(55)));
533
+ console.log(chalk.green('✓') + ' IDE Bridge listening on port ' + chalk.cyan(port));
534
+ console.log(chalk.dim('Waiting for Trace extension to connect...'));
535
+ console.log(chalk.dim('Press Ctrl+C to stop both'));
536
+ console.log(chalk.gray('─'.repeat(55)));
537
+ console.log();
538
+ });
539
+ wss.on('connection', (ws) => {
540
+ clientCount++;
541
+ connectedClients.add(ws);
542
+ console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
543
+ // Wire up the full IDE bridge protocol (file read/write, detect project, etc.)
544
+ attachMessageHandler(ws, projectPath);
545
+ // Send the last 100 lines immediately so the extension has terminal context
546
+ const catchup = globalTerminalBuffer.getLast(100);
547
+ if (catchup) {
548
+ ws.send(JSON.stringify({ type: 'TERMINAL_CATCHUP', lines: catchup }));
549
+ }
550
+ ws.on('close', () => {
551
+ clientCount--;
552
+ connectedClients.delete(ws);
553
+ console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
554
+ });
555
+ ws.on('error', (err) => {
556
+ console.error(chalk.red('WebSocket error:'), err.message);
557
+ connectedClients.delete(ws);
558
+ });
559
+ });
560
+ wss.on('error', (error) => {
561
+ if (error.code === 'EADDRINUSE') {
562
+ console.error(chalk.red(`✗ Port ${port} is already in use. Try: trace dev --port 8766`));
563
+ }
564
+ else {
565
+ console.error(chalk.red('Bridge error:'), error.message);
566
+ }
567
+ });
568
+ // ── Graceful shutdown ─────────────────────────────────────
569
+ const shutdown = () => {
570
+ console.log();
571
+ console.log(chalk.yellow('\nShutting down...'));
572
+ if (devProcess && !devProcess.killed) {
573
+ devProcess.kill('SIGTERM');
574
+ }
575
+ wss.close();
576
+ process.exit(0);
577
+ };
578
+ process.on('SIGINT', shutdown);
579
+ process.on('SIGTERM', shutdown);
580
+ });
581
+ // ============================================
582
+ // SHARED MESSAGE HANDLER
583
+ // Called by both `trace dev` and `trace connect`.
584
+ // Attaches the full IDE bridge protocol to a WS.
585
+ // ============================================
586
+ function attachMessageHandler(ws, projectPath) {
587
+ // Per-connection undo stack
588
+ const undoStack = [];
589
+ // Build project info for GET_PROJECT_INFO responses
395
590
  let projectInfo = { projectPath };
396
591
  try {
397
592
  const pkgPath = path.join(projectPath, 'package.json');
398
593
  if (fs.existsSync(pkgPath)) {
399
594
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
400
- projectInfo = {
401
- ...projectInfo,
402
- name: pkg.name,
403
- version: pkg.version,
404
- description: pkg.description
405
- };
406
- console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
595
+ projectInfo = { ...projectInfo, name: pkg.name, version: pkg.version, description: pkg.description };
407
596
  }
408
597
  }
409
- catch (e) {
410
- // No package.json, that's fine
411
- }
412
- // Start WebSocket server
413
- const wss = new WebSocketServer({ port });
414
- let clientCount = 0;
415
- console.log();
416
- console.log(chalk.green('✓') + ' WebSocket server started');
417
- console.log(chalk.dim('Waiting for extension to connect...'));
418
- console.log();
419
- console.log(chalk.gray(''.repeat(55)));
420
- console.log(chalk.dim('Press Ctrl+C to stop'));
421
- console.log();
422
- wss.on('connection', (ws) => {
423
- clientCount++;
424
- console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
425
- // Undo stack: stores file snapshots before each write/append/edit
426
- const undoStack = [];
427
- ws.on('message', async (data) => {
428
- try {
429
- const message = JSON.parse(data.toString());
430
- const { id, type } = message;
431
- let response = { id };
432
- switch (type) {
433
- case 'GET_PROJECT_INFO':
434
- response.data = projectInfo;
435
- break;
436
- case 'READ_FILE':
437
- try {
438
- const filePath = path.resolve(projectPath, message.filePath);
439
- if (!filePath.startsWith(projectPath)) {
440
- response.error = 'Access denied';
441
- }
442
- else if (fs.existsSync(filePath)) {
443
- const stat = fs.statSync(filePath);
444
- // Binary file detection (check first 4KB for null bytes)
445
- if (stat.size > 0) {
446
- const fd = fs.openSync(filePath, 'r');
447
- const sample = Buffer.alloc(Math.min(4096, stat.size));
448
- fs.readSync(fd, sample, 0, sample.length, 0);
449
- fs.closeSync(fd);
450
- if (sample.includes(0)) {
451
- response.error = `Cannot read binary file: ${message.filePath}`;
452
- break;
453
- }
454
- }
455
- const rawContent = fs.readFileSync(filePath, 'utf-8');
456
- const allLines = rawContent.split('\n');
457
- const offset = message.offset || 1;
458
- const limit = message.limit || 2000;
459
- const startIdx = Math.max(0, offset - 1);
460
- const endIdx = Math.min(allLines.length, startIdx + limit);
461
- const sliced = allLines.slice(startIdx, endIdx);
462
- // Line-number prefixed output (like OpenCode)
463
- const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
464
- const truncated = endIdx < allLines.length;
465
- response.data = {
466
- content: numbered,
467
- rawContent: rawContent,
468
- exists: true,
469
- path: filePath,
470
- totalLines: allLines.length,
471
- showing: { from: offset, to: endIdx, truncated },
472
- };
473
- }
474
- else {
475
- // Suggest similar files if not found
476
- const dir = path.dirname(filePath);
477
- const base = path.basename(filePath);
478
- let suggestions = [];
479
- try {
480
- suggestions = fs.readdirSync(dir)
481
- .filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
482
- .slice(0, 3)
483
- .map(e => path.relative(projectPath, path.join(dir, e)));
598
+ catch (_) { }
599
+ ws.on('message', async (data) => {
600
+ try {
601
+ const message = JSON.parse(data.toString());
602
+ const { id, type } = message;
603
+ let response = { id };
604
+ switch (type) {
605
+ case 'GET_PROJECT_INFO':
606
+ response.data = projectInfo;
607
+ break;
608
+ case 'READ_FILE':
609
+ try {
610
+ const filePath = path.resolve(projectPath, message.filePath);
611
+ if (!filePath.startsWith(projectPath)) {
612
+ response.error = 'Access denied';
613
+ }
614
+ else if (fs.existsSync(filePath)) {
615
+ const stat = fs.statSync(filePath);
616
+ // Binary file detection (check first 4KB for null bytes)
617
+ if (stat.size > 0) {
618
+ const fd = fs.openSync(filePath, 'r');
619
+ const sample = Buffer.alloc(Math.min(4096, stat.size));
620
+ fs.readSync(fd, sample, 0, sample.length, 0);
621
+ fs.closeSync(fd);
622
+ if (sample.includes(0)) {
623
+ response.error = `Cannot read binary file: ${message.filePath}`;
624
+ break;
484
625
  }
485
- catch (_) { }
486
- response.data = { exists: false, suggestions };
487
626
  }
627
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
628
+ const allLines = rawContent.split('\n');
629
+ const offset = message.offset || 1;
630
+ const limit = message.limit || 2000;
631
+ const startIdx = Math.max(0, offset - 1);
632
+ const endIdx = Math.min(allLines.length, startIdx + limit);
633
+ const sliced = allLines.slice(startIdx, endIdx);
634
+ // Line-number prefixed output (like OpenCode)
635
+ const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
636
+ const truncated = endIdx < allLines.length;
637
+ response.data = {
638
+ content: numbered,
639
+ rawContent: rawContent,
640
+ exists: true,
641
+ path: filePath,
642
+ totalLines: allLines.length,
643
+ showing: { from: offset, to: endIdx, truncated },
644
+ };
488
645
  }
489
- catch (e) {
490
- response.error = e.message;
491
- }
492
- break;
493
- case 'GET_SOURCE':
494
- try {
495
- const filePath = path.resolve(projectPath, message.filePath);
496
- if (!filePath.startsWith(projectPath)) {
497
- response.error = 'Access denied';
498
- }
499
- else if (fs.existsSync(filePath)) {
500
- const content = fs.readFileSync(filePath, 'utf-8');
501
- const lines = content.split('\n');
502
- const start = Math.max(0, (message.lineStart || 1) - 1);
503
- const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
504
- response.data = {
505
- lines: lines.slice(start, end),
506
- startLine: start + 1,
507
- endLine: end,
508
- totalLines: lines.length
509
- };
510
- }
511
- else {
512
- response.error = 'File not found';
646
+ else {
647
+ // Suggest similar files if not found
648
+ const dir = path.dirname(filePath);
649
+ const base = path.basename(filePath);
650
+ let suggestions = [];
651
+ try {
652
+ suggestions = fs.readdirSync(dir)
653
+ .filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
654
+ .slice(0, 3)
655
+ .map(e => path.relative(projectPath, path.join(dir, e)));
513
656
  }
657
+ catch (_) { }
658
+ response.data = { exists: false, suggestions };
514
659
  }
515
- catch (e) {
516
- response.error = e.message;
517
- }
518
- break;
519
- case 'GET_ERROR_CONTEXT':
520
- try {
521
- const filePath = path.resolve(projectPath, message.filePath);
522
- if (!filePath.startsWith(projectPath)) {
523
- response.error = 'Access denied';
524
- }
525
- else if (fs.existsSync(filePath)) {
526
- const content = fs.readFileSync(filePath, 'utf-8');
527
- const lines = content.split('\n');
528
- const targetLine = message.line || 1;
529
- const contextLines = message.contextLines || 5;
530
- const start = Math.max(0, targetLine - contextLines - 1);
531
- const end = Math.min(lines.length, targetLine + contextLines);
532
- response.data = {
533
- lines: lines.slice(start, end).map((line, i) => ({
534
- number: start + i + 1,
535
- content: line,
536
- isError: start + i + 1 === targetLine
537
- })),
538
- errorLine: targetLine,
539
- filePath: message.filePath
540
- };
541
- }
542
- else {
543
- response.error = 'File not found';
544
- }
660
+ }
661
+ catch (e) {
662
+ response.error = e.message;
663
+ }
664
+ break;
665
+ case 'GET_SOURCE':
666
+ try {
667
+ const filePath = path.resolve(projectPath, message.filePath);
668
+ if (!filePath.startsWith(projectPath)) {
669
+ response.error = 'Access denied';
545
670
  }
546
- catch (e) {
547
- response.error = e.message;
671
+ else if (fs.existsSync(filePath)) {
672
+ const content = fs.readFileSync(filePath, 'utf-8');
673
+ const lines = content.split('\n');
674
+ const start = Math.max(0, (message.lineStart || 1) - 1);
675
+ const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
676
+ response.data = {
677
+ lines: lines.slice(start, end),
678
+ startLine: start + 1,
679
+ endLine: end,
680
+ totalLines: lines.length
681
+ };
548
682
  }
549
- break;
550
- case 'GET_FILE_TREE':
551
- try {
552
- const depth = message.depth || 3;
553
- const tree = getFileTree(projectPath, depth);
554
- response.data = { tree };
683
+ else {
684
+ response.error = 'File not found';
555
685
  }
556
- catch (e) {
557
- response.error = e.message;
686
+ }
687
+ catch (e) {
688
+ response.error = e.message;
689
+ }
690
+ break;
691
+ case 'GET_ERROR_CONTEXT':
692
+ try {
693
+ const filePath = path.resolve(projectPath, message.filePath);
694
+ if (!filePath.startsWith(projectPath)) {
695
+ response.error = 'Access denied';
558
696
  }
559
- break;
560
- case 'SEARCH_CODE':
561
- try {
562
- const query = message.query;
563
- const matches = searchInFiles(projectPath, query, 20);
564
- response.data = { matches };
697
+ else if (fs.existsSync(filePath)) {
698
+ const content = fs.readFileSync(filePath, 'utf-8');
699
+ const lines = content.split('\n');
700
+ const targetLine = message.line || 1;
701
+ const contextLines = message.contextLines || 5;
702
+ const start = Math.max(0, targetLine - contextLines - 1);
703
+ const end = Math.min(lines.length, targetLine + contextLines);
704
+ response.data = {
705
+ lines: lines.slice(start, end).map((line, i) => ({
706
+ number: start + i + 1,
707
+ content: line,
708
+ isError: start + i + 1 === targetLine
709
+ })),
710
+ errorLine: targetLine,
711
+ filePath: message.filePath
712
+ };
565
713
  }
566
- catch (e) {
567
- response.error = e.message;
714
+ else {
715
+ response.error = 'File not found';
568
716
  }
569
- break;
570
- case 'FIND_FILES':
571
- try {
572
- const pattern = message.pattern || '';
573
- // Extract the filename from a glob like **/Services.css
574
- const fileName = pattern.split('/').pop().replace(/\*\*/g, '').replace(/\*/g, '');
575
- const maxResults = message.maxResults || 20;
576
- const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
577
- const found = [];
578
- function findFiles(dir) {
579
- if (found.length >= maxResults)
580
- return;
581
- try {
582
- const items = fs.readdirSync(dir);
583
- for (const item of items) {
584
- if (found.length >= maxResults)
585
- break;
586
- if (ignorePatterns.includes(item) || item.startsWith('.'))
587
- continue;
588
- const fullPath = path.join(dir, item);
589
- try {
590
- const stat = fs.statSync(fullPath);
591
- if (stat.isDirectory()) {
592
- findFiles(fullPath);
593
- }
594
- else {
595
- // Match by filename (with optional wildcard)
596
- const matchByName = !fileName || item === fileName ||
597
- (pattern.includes('*') && item.endsWith(fileName));
598
- if (matchByName) {
599
- found.push(path.relative(projectPath, fullPath));
600
- }
717
+ }
718
+ catch (e) {
719
+ response.error = e.message;
720
+ }
721
+ break;
722
+ case 'GET_FILE_TREE':
723
+ try {
724
+ const depth = message.depth || 3;
725
+ const tree = getFileTree(projectPath, depth);
726
+ response.data = { tree };
727
+ }
728
+ catch (e) {
729
+ response.error = e.message;
730
+ }
731
+ break;
732
+ case 'SEARCH_CODE':
733
+ try {
734
+ const query = message.query;
735
+ const matches = searchInFiles(projectPath, query, 20);
736
+ response.data = { matches };
737
+ }
738
+ catch (e) {
739
+ response.error = e.message;
740
+ }
741
+ break;
742
+ case 'FIND_FILES':
743
+ try {
744
+ const pattern = message.pattern || '';
745
+ // Extract the filename from a glob like **/Services.css
746
+ const fileName = pattern.split('/').pop().replace(/\*\*/g, '').replace(/\*/g, '');
747
+ const maxResults = message.maxResults || 20;
748
+ const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
749
+ const found = [];
750
+ function findFiles(dir) {
751
+ if (found.length >= maxResults)
752
+ return;
753
+ try {
754
+ const items = fs.readdirSync(dir);
755
+ for (const item of items) {
756
+ if (found.length >= maxResults)
757
+ break;
758
+ if (ignorePatterns.includes(item) || item.startsWith('.'))
759
+ continue;
760
+ const fullPath = path.join(dir, item);
761
+ try {
762
+ const stat = fs.statSync(fullPath);
763
+ if (stat.isDirectory()) {
764
+ findFiles(fullPath);
765
+ }
766
+ else {
767
+ // Match by filename (with optional wildcard)
768
+ const matchByName = !fileName || item === fileName ||
769
+ (pattern.includes('*') && item.endsWith(fileName));
770
+ if (matchByName) {
771
+ found.push(path.relative(projectPath, fullPath));
601
772
  }
602
773
  }
603
- catch (e) { /* skip */ }
604
774
  }
775
+ catch (e) { /* skip */ }
605
776
  }
606
- catch (e) { /* skip */ }
607
777
  }
608
- findFiles(projectPath);
609
- response.data = found;
778
+ catch (e) { /* skip */ }
610
779
  }
611
- catch (e) {
612
- response.error = e.message;
780
+ findFiles(projectPath);
781
+ response.data = found;
782
+ }
783
+ catch (e) {
784
+ response.error = e.message;
785
+ }
786
+ // ========== PROJECT DETECTION (Code Export Pipeline) ==========
787
+ case 'DETECT_PROJECT':
788
+ try {
789
+ const pkgPath = path.join(projectPath, 'package.json');
790
+ let deps = {};
791
+ let devDeps = {};
792
+ let pkgName = '';
793
+ if (fs.existsSync(pkgPath)) {
794
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
795
+ deps = pkg.dependencies || {};
796
+ devDeps = pkg.devDependencies || {};
797
+ pkgName = pkg.name || '';
613
798
  }
614
- // ========== PROJECT DETECTION (Code Export Pipeline) ==========
615
- case 'DETECT_PROJECT':
616
- try {
617
- const pkgPath = path.join(projectPath, 'package.json');
618
- let deps = {};
619
- let devDeps = {};
620
- let pkgName = '';
621
- if (fs.existsSync(pkgPath)) {
622
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
623
- deps = pkg.dependencies || {};
624
- devDeps = pkg.devDependencies || {};
625
- pkgName = pkg.name || '';
799
+ const allDeps = { ...deps, ...devDeps };
800
+ // --- Framework detection ---
801
+ let framework = 'html';
802
+ if (allDeps['next'])
803
+ framework = 'next.js';
804
+ else if (allDeps['nuxt'] || allDeps['nuxt3'])
805
+ framework = 'nuxt';
806
+ else if (allDeps['gatsby'])
807
+ framework = 'gatsby';
808
+ else if (allDeps['@remix-run/react'])
809
+ framework = 'remix';
810
+ else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
811
+ framework = 'svelte';
812
+ else if (allDeps['vue'])
813
+ framework = 'vue';
814
+ else if (allDeps['@angular/core'])
815
+ framework = 'angular';
816
+ else if (allDeps['react'])
817
+ framework = 'react';
818
+ // --- TypeScript detection ---
819
+ const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
820
+ const ext = typescript ? '.tsx' : '.jsx';
821
+ // --- Styling detection ---
822
+ let styling = 'plain-css';
823
+ if (allDeps['tailwindcss'])
824
+ styling = 'tailwind';
825
+ else if (allDeps['styled-components'])
826
+ styling = 'styled-components';
827
+ else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
828
+ styling = 'emotion';
829
+ else if (allDeps['sass'] || allDeps['node-sass'])
830
+ styling = 'sass';
831
+ // CSS Modules: detected by file presence later
832
+ // --- Router detection (Next.js specific) ---
833
+ let router = null;
834
+ if (framework === 'next.js') {
835
+ if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
836
+ router = 'app';
626
837
  }
627
- const allDeps = { ...deps, ...devDeps };
628
- // --- Framework detection ---
629
- let framework = 'html';
630
- if (allDeps['next'])
631
- framework = 'next.js';
632
- else if (allDeps['nuxt'] || allDeps['nuxt3'])
633
- framework = 'nuxt';
634
- else if (allDeps['gatsby'])
635
- framework = 'gatsby';
636
- else if (allDeps['@remix-run/react'])
637
- framework = 'remix';
638
- else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
639
- framework = 'svelte';
640
- else if (allDeps['vue'])
641
- framework = 'vue';
642
- else if (allDeps['@angular/core'])
643
- framework = 'angular';
644
- else if (allDeps['react'])
645
- framework = 'react';
646
- // --- TypeScript detection ---
647
- const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
648
- const ext = typescript ? '.tsx' : '.jsx';
649
- // --- Styling detection ---
650
- let styling = 'plain-css';
651
- if (allDeps['tailwindcss'])
652
- styling = 'tailwind';
653
- else if (allDeps['styled-components'])
654
- styling = 'styled-components';
655
- else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
656
- styling = 'emotion';
657
- else if (allDeps['sass'] || allDeps['node-sass'])
658
- styling = 'sass';
659
- // CSS Modules: detected by file presence later
660
- // --- Router detection (Next.js specific) ---
661
- let router = null;
662
- if (framework === 'next.js') {
663
- if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
664
- router = 'app';
665
- }
666
- else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
667
- router = 'pages';
668
- }
838
+ else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
839
+ router = 'pages';
669
840
  }
670
- // --- Key file location finder ---
671
- const findFirst = (candidates) => {
672
- for (const c of candidates) {
673
- if (fs.existsSync(path.join(projectPath, c)))
674
- return c;
675
- }
676
- return null;
677
- };
678
- const layoutFile = findFirst([
679
- 'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
680
- 'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
681
- 'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
682
- 'src/App.tsx', 'src/App.jsx', 'src/App.js',
683
- 'src/main.tsx', 'src/main.jsx', 'src/main.js',
684
- 'index.html'
685
- ]);
686
- const globalStyleFile = findFirst([
687
- 'src/app/globals.css', 'src/app/global.css',
688
- 'app/globals.css', 'app/global.css',
689
- 'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
690
- 'src/App.css', 'src/styles.css',
691
- 'styles/globals.css', 'styles/global.css',
692
- 'css/style.css', 'css/main.css',
693
- 'style.css', 'styles.css'
694
- ]);
695
- // Initial guess via disk — overridden after tree scan with real component count
696
- let componentsDir = findFirst([
697
- 'src/components', 'src/app/components', 'components',
698
- 'src/ui', 'src/app/ui'
699
- ]);
700
- // --- Tailwind config ---
701
- let tailwindConfig = null;
702
- let tailwindVersion = null;
703
- if (styling === 'tailwind') {
704
- tailwindConfig = findFirst([
705
- 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
706
- ]);
707
- tailwindVersion = allDeps['tailwindcss'] || null;
841
+ }
842
+ // --- Key file location finder ---
843
+ const findFirst = (candidates) => {
844
+ for (const c of candidates) {
845
+ if (fs.existsSync(path.join(projectPath, c)))
846
+ return c;
708
847
  }
709
- // --- Detect code conventions from existing components ---
710
- let componentPattern = 'arrow-function';
711
- let exportPattern = 'default';
712
- let sampleComponentFile = null;
713
- let sampleComponentCode = null;
714
- if (componentsDir) {
715
- const compDir = path.join(projectPath, componentsDir);
716
- try {
717
- const files = fs.readdirSync(compDir).filter(f => f.endsWith('.tsx') || f.endsWith('.jsx') || f.endsWith('.js') || f.endsWith('.vue') || f.endsWith('.svelte'));
718
- if (files.length > 0) {
719
- sampleComponentFile = path.join(componentsDir, files[0]);
720
- const code = fs.readFileSync(path.join(compDir, files[0]), 'utf-8');
721
- // Only include first 80 lines as a pattern reference
722
- sampleComponentCode = code.split('\n').slice(0, 80).join('\n');
723
- // Detect patterns
724
- if (/^export default function /m.test(code)) {
725
- componentPattern = 'function-declaration';
726
- exportPattern = 'default';
727
- }
728
- else if (/^export function /m.test(code)) {
729
- componentPattern = 'function-declaration';
730
- exportPattern = 'named';
731
- }
732
- else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
733
- componentPattern = 'arrow-function';
734
- }
735
- if (/^export default /m.test(code))
736
- exportPattern = 'default';
737
- else if (/^export \{/m.test(code) || /^export const/m.test(code))
738
- exportPattern = 'named';
739
- // CSS Modules detection from component imports
740
- if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
741
- styling = 'css-modules';
742
- }
848
+ return null;
849
+ };
850
+ const layoutFile = findFirst([
851
+ 'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
852
+ 'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
853
+ 'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
854
+ 'src/App.tsx', 'src/App.jsx', 'src/App.js',
855
+ 'src/main.tsx', 'src/main.jsx', 'src/main.js',
856
+ 'index.html'
857
+ ]);
858
+ const globalStyleFile = findFirst([
859
+ 'src/app/globals.css', 'src/app/global.css',
860
+ 'app/globals.css', 'app/global.css',
861
+ 'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
862
+ 'src/App.css', 'src/styles.css',
863
+ 'styles/globals.css', 'styles/global.css',
864
+ 'css/style.css', 'css/main.css',
865
+ 'style.css', 'styles.css'
866
+ ]);
867
+ // Initial guess via disk — overridden after tree scan with real component count
868
+ let componentsDir = findFirst([
869
+ 'src/components', 'src/app/components', 'components',
870
+ 'src/ui', 'src/app/ui'
871
+ ]);
872
+ // --- Tailwind config ---
873
+ let tailwindConfig = null;
874
+ let tailwindVersion = null;
875
+ if (styling === 'tailwind') {
876
+ tailwindConfig = findFirst([
877
+ 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
878
+ ]);
879
+ tailwindVersion = allDeps['tailwindcss'] || null;
880
+ }
881
+ // --- Detect code conventions from existing components ---
882
+ let componentPattern = 'arrow-function';
883
+ let exportPattern = 'default';
884
+ let sampleComponentFile = null;
885
+ let sampleComponentCode = null;
886
+ if (componentsDir) {
887
+ const compDir = path.join(projectPath, componentsDir);
888
+ try {
889
+ const files = fs.readdirSync(compDir).filter(f => f.endsWith('.tsx') || f.endsWith('.jsx') || f.endsWith('.js') || f.endsWith('.vue') || f.endsWith('.svelte'));
890
+ if (files.length > 0) {
891
+ sampleComponentFile = path.join(componentsDir, files[0]);
892
+ const code = fs.readFileSync(path.join(compDir, files[0]), 'utf-8');
893
+ // Only include first 80 lines as a pattern reference
894
+ sampleComponentCode = code.split('\n').slice(0, 80).join('\n');
895
+ // Detect patterns
896
+ if (/^export default function /m.test(code)) {
897
+ componentPattern = 'function-declaration';
898
+ exportPattern = 'default';
743
899
  }
744
- }
745
- catch (e) { /* skip */ }
746
- }
747
- // --- Also check layout file for CSS Modules if not yet detected ---
748
- if (styling === 'plain-css' && layoutFile) {
749
- try {
750
- const layoutCode = fs.readFileSync(path.join(projectPath, layoutFile), 'utf-8');
751
- if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
900
+ else if (/^export function /m.test(code)) {
901
+ componentPattern = 'function-declaration';
902
+ exportPattern = 'named';
903
+ }
904
+ else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
905
+ componentPattern = 'arrow-function';
906
+ }
907
+ if (/^export default /m.test(code))
908
+ exportPattern = 'default';
909
+ else if (/^export \{/m.test(code) || /^export const/m.test(code))
910
+ exportPattern = 'named';
911
+ // CSS Modules detection from component imports
912
+ if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
752
913
  styling = 'css-modules';
753
914
  }
754
915
  }
755
- catch (e) { /* skip */ }
756
916
  }
757
- // --- Read a snippet of global CSS for context ---
758
- let globalStylePreview = null;
759
- if (globalStyleFile) {
760
- try {
761
- const css = fs.readFileSync(path.join(projectPath, globalStyleFile), 'utf-8');
762
- globalStylePreview = css.split('\n').slice(0, 40).join('\n');
917
+ catch (e) { /* skip */ }
918
+ }
919
+ // --- Also check layout file for CSS Modules if not yet detected ---
920
+ if (styling === 'plain-css' && layoutFile) {
921
+ try {
922
+ const layoutCode = fs.readFileSync(path.join(projectPath, layoutFile), 'utf-8');
923
+ if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
924
+ styling = 'css-modules';
763
925
  }
764
- catch (e) { /* skip */ }
765
926
  }
766
- // --- Build filtered scaffolding tree (UI-relevant files only) ---
767
- const SKIP_DIRS = new Set([
768
- 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
769
- 'dist', 'build', '.cache', '.turbo', 'coverage',
770
- '__pycache__', '.vercel', '.output', '.parcel-cache'
771
- ]);
772
- const UI_EXTENSIONS = new Set([
773
- '.tsx', '.jsx', '.js', '.ts', '.vue', '.svelte',
774
- '.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
775
- '.html', '.astro'
776
- ]);
777
- const treeLines = [];
778
- const MAX_TREE_LINES = 120;
779
- // Track dir count of Pascal-case component files for smart detection
780
- const dirComponentCount = new Map();
781
- const buildTree = (dir, prefix, depth) => {
782
- if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
783
- return;
784
- try {
785
- const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
786
- // Sort: directories first, then files
787
- const sorted = entries
788
- .filter(e => !e.name.startsWith('.') || e.name === '.env')
789
- .sort((a, b) => {
790
- if (a.isDirectory() && !b.isDirectory())
791
- return -1;
792
- if (!a.isDirectory() && b.isDirectory())
793
- return 1;
794
- return a.name.localeCompare(b.name);
795
- });
796
- for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
797
- const entry = sorted[i];
798
- const isLast = i === sorted.length - 1;
799
- const connector = isLast ? '└── ' : '├── ';
800
- const childPrefix = isLast ? ' ' : '│ ';
801
- const childPath = dir ? `${dir}/${entry.name}` : entry.name;
802
- if (entry.isDirectory()) {
803
- if (SKIP_DIRS.has(entry.name))
804
- continue;
805
- treeLines.push(`${prefix}${connector}${entry.name}/`);
806
- buildTree(childPath, prefix + childPrefix, depth + 1);
927
+ catch (e) { /* skip */ }
928
+ }
929
+ // --- Read a snippet of global CSS for context ---
930
+ let globalStylePreview = null;
931
+ if (globalStyleFile) {
932
+ try {
933
+ const css = fs.readFileSync(path.join(projectPath, globalStyleFile), 'utf-8');
934
+ globalStylePreview = css.split('\n').slice(0, 40).join('\n');
935
+ }
936
+ catch (e) { /* skip */ }
937
+ }
938
+ // --- Build filtered scaffolding tree (UI-relevant files only) ---
939
+ const SKIP_DIRS = new Set([
940
+ 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
941
+ 'dist', 'build', '.cache', '.turbo', 'coverage',
942
+ '__pycache__', '.vercel', '.output', '.parcel-cache'
943
+ ]);
944
+ const UI_EXTENSIONS = new Set([
945
+ '.tsx', '.jsx', '.js', '.ts', '.vue', '.svelte',
946
+ '.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
947
+ '.html', '.astro'
948
+ ]);
949
+ const treeLines = [];
950
+ const MAX_TREE_LINES = 120;
951
+ // Track dir → count of Pascal-case component files for smart detection
952
+ const dirComponentCount = new Map();
953
+ const buildTree = (dir, prefix, depth) => {
954
+ if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
955
+ return;
956
+ try {
957
+ const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
958
+ // Sort: directories first, then files
959
+ const sorted = entries
960
+ .filter(e => !e.name.startsWith('.') || e.name === '.env')
961
+ .sort((a, b) => {
962
+ if (a.isDirectory() && !b.isDirectory())
963
+ return -1;
964
+ if (!a.isDirectory() && b.isDirectory())
965
+ return 1;
966
+ return a.name.localeCompare(b.name);
967
+ });
968
+ for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
969
+ const entry = sorted[i];
970
+ const isLast = i === sorted.length - 1;
971
+ const connector = isLast ? '└── ' : '├── ';
972
+ const childPrefix = isLast ? ' ' : '│ ';
973
+ const childPath = dir ? `${dir}/${entry.name}` : entry.name;
974
+ if (entry.isDirectory()) {
975
+ if (SKIP_DIRS.has(entry.name))
976
+ continue;
977
+ treeLines.push(`${prefix}${connector}${entry.name}/`);
978
+ buildTree(childPath, prefix + childPrefix, depth + 1);
979
+ }
980
+ else {
981
+ const ext = path.extname(entry.name).toLowerCase();
982
+ // Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
983
+ const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
984
+ if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
985
+ dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
807
986
  }
808
- else {
809
- const ext = path.extname(entry.name).toLowerCase();
810
- // Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
811
- const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
812
- if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
813
- dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
814
- }
815
- // Include UI-relevant files + config files at root
816
- if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
817
- entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
818
- entry.name.startsWith('vite.config')))) {
819
- treeLines.push(`${prefix}${connector}${entry.name}`);
820
- }
987
+ // Include UI-relevant files + config files at root
988
+ if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
989
+ entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
990
+ entry.name.startsWith('vite.config')))) {
991
+ treeLines.push(`${prefix}${connector}${entry.name}`);
821
992
  }
822
993
  }
823
994
  }
824
- catch (e) { /* permission error, skip */ }
825
- };
826
- // Only scan UI-relevant root dirs (not the entire project)
827
- const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
828
- const existingRoots = [];
829
- for (const root of scanRoots) {
830
- if (fs.existsSync(path.join(projectPath, root))) {
831
- existingRoots.push(root);
832
- }
833
995
  }
834
- // Also add root-level config files
835
- try {
836
- const rootEntries = fs.readdirSync(projectPath, { withFileTypes: true });
837
- for (const entry of rootEntries) {
838
- if (!entry.isDirectory() && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
839
- entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
840
- entry.name.startsWith('vite.config') || entry.name === 'index.html')) {
841
- treeLines.push(entry.name);
842
- }
996
+ catch (e) { /* permission error, skip */ }
997
+ };
998
+ // Only scan UI-relevant root dirs (not the entire project)
999
+ const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
1000
+ const existingRoots = [];
1001
+ for (const root of scanRoots) {
1002
+ if (fs.existsSync(path.join(projectPath, root))) {
1003
+ existingRoots.push(root);
1004
+ }
1005
+ }
1006
+ // Also add root-level config files
1007
+ try {
1008
+ const rootEntries = fs.readdirSync(projectPath, { withFileTypes: true });
1009
+ for (const entry of rootEntries) {
1010
+ if (!entry.isDirectory() && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
1011
+ entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
1012
+ entry.name.startsWith('vite.config') || entry.name === 'index.html')) {
1013
+ treeLines.push(entry.name);
843
1014
  }
844
1015
  }
845
- catch (e) { /* skip */ }
846
- // Build tree for each UI root
847
- for (const root of existingRoots) {
848
- treeLines.push(`${root}/`);
849
- buildTree(root, '', 1);
1016
+ }
1017
+ catch (e) { /* skip */ }
1018
+ // Build tree for each UI root
1019
+ for (const root of existingRoots) {
1020
+ treeLines.push(`${root}/`);
1021
+ buildTree(root, '', 1);
1022
+ }
1023
+ const projectTree = treeLines.length > 0 ? treeLines.join('\n') : null;
1024
+ // --- Override componentsDir with real scan result ---
1025
+ // Pick the dir with the most Pascal-case component files.
1026
+ // This beats any hardcoded list: works for src/features, src/modules,
1027
+ // src/views, src/app/components — whatever the project uses.
1028
+ if (dirComponentCount.size > 0) {
1029
+ let bestDir = '';
1030
+ let bestCount = 0;
1031
+ for (const [dir, count] of dirComponentCount.entries()) {
1032
+ if (count > bestCount) {
1033
+ bestCount = count;
1034
+ bestDir = dir;
1035
+ }
850
1036
  }
851
- const projectTree = treeLines.length > 0 ? treeLines.join('\n') : null;
852
- // --- Override componentsDir with real scan result ---
853
- // Pick the dir with the most Pascal-case component files.
854
- // This beats any hardcoded list: works for src/features, src/modules,
855
- // src/views, src/app/components — whatever the project uses.
856
- if (dirComponentCount.size > 0) {
857
- let bestDir = '';
858
- let bestCount = 0;
859
- for (const [dir, count] of dirComponentCount.entries()) {
860
- if (count > bestCount) {
861
- bestCount = count;
862
- bestDir = dir;
863
- }
1037
+ if (bestCount > 0)
1038
+ componentsDir = bestDir;
1039
+ }
1040
+ response.data = {
1041
+ framework,
1042
+ typescript,
1043
+ styling,
1044
+ router,
1045
+ ext,
1046
+ pkgName,
1047
+ // Key file locations
1048
+ layoutFile,
1049
+ globalStyleFile,
1050
+ componentsDir,
1051
+ tailwindConfig,
1052
+ tailwindVersion,
1053
+ // Code conventions
1054
+ componentPattern,
1055
+ exportPattern,
1056
+ sampleComponentFile,
1057
+ sampleComponentCode,
1058
+ // CSS context
1059
+ globalStylePreview,
1060
+ // Scaffolding tree
1061
+ projectTree,
1062
+ };
1063
+ console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
1064
+ }
1065
+ catch (e) {
1066
+ response.error = e.message;
1067
+ }
1068
+ break;
1069
+ // ========== FILE WRITING (UI Design Export) ==========
1070
+ // --- Undo Stack ---
1071
+ // Snapshots file contents before every write/append/edit.
1072
+ // UNDO_LAST pops the most recent snapshot and restores it.
1073
+ // Max 20 entries to avoid unbounded memory growth.
1074
+ case 'WRITE_FILE':
1075
+ try {
1076
+ const filePath = path.resolve(projectPath, message.filePath);
1077
+ if (!filePath.startsWith(projectPath)) {
1078
+ response.error = 'Access denied: Path outside project';
1079
+ }
1080
+ else {
1081
+ // Snapshot before write
1082
+ const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
1083
+ undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
1084
+ if (undoStack.length > 20)
1085
+ undoStack.shift();
1086
+ // --- CSS safety: validate balanced braces ---
1087
+ if (filePath.endsWith('.css')) {
1088
+ const writeContent = message.content || '';
1089
+ const opens = (writeContent.match(/\{/g) || []).length;
1090
+ const closes = (writeContent.match(/\}/g) || []).length;
1091
+ if (opens !== closes) {
1092
+ // Remove snapshot since we're rejecting the write
1093
+ undoStack.pop();
1094
+ response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
1095
+ break;
864
1096
  }
865
- if (bestCount > 0)
866
- componentsDir = bestDir;
867
1097
  }
868
- response.data = {
869
- framework,
870
- typescript,
871
- styling,
872
- router,
873
- ext,
874
- pkgName,
875
- // Key file locations
876
- layoutFile,
877
- globalStyleFile,
878
- componentsDir,
879
- tailwindConfig,
880
- tailwindVersion,
881
- // Code conventions
882
- componentPattern,
883
- exportPattern,
884
- sampleComponentFile,
885
- sampleComponentCode,
886
- // CSS context
887
- globalStylePreview,
888
- // Scaffolding tree
889
- projectTree,
890
- };
891
- console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
1098
+ const dirPath = path.dirname(filePath);
1099
+ if (!fs.existsSync(dirPath)) {
1100
+ fs.mkdirSync(dirPath, { recursive: true });
1101
+ }
1102
+ fs.writeFileSync(filePath, message.content, 'utf-8');
1103
+ // Auto-format after write
1104
+ const formatter = await autoFormat(filePath, projectPath);
1105
+ response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1106
+ console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
892
1107
  }
893
- catch (e) {
894
- response.error = e.message;
1108
+ }
1109
+ catch (e) {
1110
+ response.error = e.message;
1111
+ }
1112
+ break;
1113
+ case 'APPEND_FILE':
1114
+ try {
1115
+ const filePath = path.resolve(projectPath, message.filePath);
1116
+ if (!filePath.startsWith(projectPath)) {
1117
+ response.error = 'Access denied: Path outside project';
895
1118
  }
896
- break;
897
- // ========== FILE WRITING (UI Design Export) ==========
898
- // --- Undo Stack ---
899
- // Snapshots file contents before every write/append/edit.
900
- // UNDO_LAST pops the most recent snapshot and restores it.
901
- // Max 20 entries to avoid unbounded memory growth.
902
- case 'WRITE_FILE':
903
- try {
904
- const filePath = path.resolve(projectPath, message.filePath);
905
- if (!filePath.startsWith(projectPath)) {
906
- response.error = 'Access denied: Path outside project';
1119
+ else {
1120
+ const isCssFile = filePath.endsWith('.css');
1121
+ const newContent = message.content || '';
1122
+ // --- CSS safety: validate balanced braces ---
1123
+ if (isCssFile) {
1124
+ const opens = (newContent.match(/\{/g) || []).length;
1125
+ const closes = (newContent.match(/\}/g) || []).length;
1126
+ if (opens !== closes) {
1127
+ response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
1128
+ break;
1129
+ }
907
1130
  }
908
- else {
909
- // Snapshot before write
910
- const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
911
- undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
912
- if (undoStack.length > 20)
913
- undoStack.shift();
914
- // --- CSS safety: validate balanced braces ---
915
- if (filePath.endsWith('.css')) {
916
- const writeContent = message.content || '';
917
- const opens = (writeContent.match(/\{/g) || []).length;
918
- const closes = (writeContent.match(/\}/g) || []).length;
919
- if (opens !== closes) {
920
- // Remove snapshot since we're rejecting the write
921
- undoStack.pop();
922
- response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
923
- break;
1131
+ // Snapshot before append
1132
+ const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
1133
+ undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
1134
+ if (undoStack.length > 20)
1135
+ undoStack.shift();
1136
+ // --- CSS safety: hoist @import/@charset to file top ---
1137
+ if (isCssFile && prevContent !== null) {
1138
+ // Extract @import and @charset from the NEW content
1139
+ const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
1140
+ const newImports = [];
1141
+ const bodyContent = newContent.replace(importRegex, (match) => {
1142
+ newImports.push(match.trim());
1143
+ return '';
1144
+ }).trim();
1145
+ if (newImports.length > 0) {
1146
+ // Find where existing imports end in the original file
1147
+ const existingLines = prevContent.split('\n');
1148
+ let lastImportLineIdx = -1;
1149
+ for (let i = 0; i < existingLines.length; i++) {
1150
+ const trimmed = existingLines[i].trim();
1151
+ if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
1152
+ lastImportLineIdx = i;
1153
+ }
1154
+ // Stop scanning after first non-import, non-comment, non-empty line
1155
+ if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
1156
+ break;
1157
+ }
924
1158
  }
1159
+ // Deduplicate: only add imports that don't already exist
1160
+ const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
1161
+ // Build new file: existing imports + new imports + rest of existing + new body
1162
+ const insertIdx = lastImportLineIdx + 1;
1163
+ const topLines = existingLines.slice(0, insertIdx);
1164
+ const restLines = existingLines.slice(insertIdx);
1165
+ const merged = [
1166
+ ...topLines,
1167
+ ...dedupedImports,
1168
+ ...restLines,
1169
+ '', // separator
1170
+ bodyContent
1171
+ ].join('\n');
1172
+ fs.writeFileSync(filePath, merged, 'utf-8');
1173
+ console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
925
1174
  }
926
- const dirPath = path.dirname(filePath);
927
- if (!fs.existsSync(dirPath)) {
928
- fs.mkdirSync(dirPath, { recursive: true });
1175
+ else {
1176
+ // No imports in new content — simple append
1177
+ fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
929
1178
  }
930
- fs.writeFileSync(filePath, message.content, 'utf-8');
931
- // Auto-format after write
932
- const formatter = await autoFormat(filePath, projectPath);
933
- response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
934
- console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
935
1179
  }
1180
+ else {
1181
+ // Non-CSS file or new file — simple append
1182
+ fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
1183
+ }
1184
+ // Auto-format after append
1185
+ const formatter = await autoFormat(filePath, projectPath);
1186
+ response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1187
+ console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
936
1188
  }
937
- catch (e) {
938
- response.error = e.message;
1189
+ }
1190
+ catch (e) {
1191
+ response.error = e.message;
1192
+ }
1193
+ break;
1194
+ case 'EDIT_FILE':
1195
+ try {
1196
+ const filePath = path.resolve(projectPath, message.filePath);
1197
+ if (!filePath.startsWith(projectPath)) {
1198
+ response.error = 'Access denied: Path outside project';
939
1199
  }
940
- break;
941
- case 'APPEND_FILE':
942
- try {
943
- const filePath = path.resolve(projectPath, message.filePath);
944
- if (!filePath.startsWith(projectPath)) {
945
- response.error = 'Access denied: Path outside project';
946
- }
947
- else {
948
- const isCssFile = filePath.endsWith('.css');
949
- const newContent = message.content || '';
950
- // --- CSS safety: validate balanced braces ---
951
- if (isCssFile) {
952
- const opens = (newContent.match(/\{/g) || []).length;
953
- const closes = (newContent.match(/\}/g) || []).length;
954
- if (opens !== closes) {
955
- response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
956
- break;
957
- }
958
- }
959
- // Snapshot before append
960
- const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
961
- undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
962
- if (undoStack.length > 20)
963
- undoStack.shift();
964
- // --- CSS safety: hoist @import/@charset to file top ---
965
- if (isCssFile && prevContent !== null) {
966
- // Extract @import and @charset from the NEW content
967
- const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
968
- const newImports = [];
969
- const bodyContent = newContent.replace(importRegex, (match) => {
970
- newImports.push(match.trim());
971
- return '';
972
- }).trim();
973
- if (newImports.length > 0) {
974
- // Find where existing imports end in the original file
975
- const existingLines = prevContent.split('\n');
976
- let lastImportLineIdx = -1;
977
- for (let i = 0; i < existingLines.length; i++) {
978
- const trimmed = existingLines[i].trim();
979
- if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
980
- lastImportLineIdx = i;
981
- }
982
- // Stop scanning after first non-import, non-comment, non-empty line
983
- if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
984
- break;
1200
+ else if (!fs.existsSync(filePath)) {
1201
+ // Find similarly-named files to help the LLM self-correct
1202
+ const requestedBase = path.basename(message.filePath);
1203
+ const requestedExt = path.extname(requestedBase);
1204
+ const requestedStem = requestedBase.replace(requestedExt, '');
1205
+ // Walk shallow project tree to find candidates
1206
+ const suggestions = [];
1207
+ const dirsToSearch = ['src/app', 'app', 'src/pages', 'pages', 'src/components', 'components', 'src'];
1208
+ for (const dir of dirsToSearch) {
1209
+ const absDir = path.join(projectPath, dir);
1210
+ if (fs.existsSync(absDir)) {
1211
+ try {
1212
+ const files = fs.readdirSync(absDir, { recursive: true });
1213
+ for (const f of files.slice(0, 100)) {
1214
+ const fBase = path.basename(f);
1215
+ // Same stem, any extension (page.tsx page.js, page.jsx)
1216
+ if (fBase.startsWith(requestedStem + '.') ||
1217
+ fBase === requestedBase) {
1218
+ suggestions.push(path.join(dir, f).replace(/\\/g, '/'));
985
1219
  }
986
1220
  }
987
- // Deduplicate: only add imports that don't already exist
988
- const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
989
- // Build new file: existing imports + new imports + rest of existing + new body
990
- const insertIdx = lastImportLineIdx + 1;
991
- const topLines = existingLines.slice(0, insertIdx);
992
- const restLines = existingLines.slice(insertIdx);
993
- const merged = [
994
- ...topLines,
995
- ...dedupedImports,
996
- ...restLines,
997
- '', // separator
998
- bodyContent
999
- ].join('\n');
1000
- fs.writeFileSync(filePath, merged, 'utf-8');
1001
- console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
1002
- }
1003
- else {
1004
- // No imports in new content — simple append
1005
- fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
1006
1221
  }
1222
+ catch (e) { /* skip unreadable dirs */ }
1007
1223
  }
1008
- else {
1009
- // Non-CSS file or new file — simple append
1010
- fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
1011
- }
1012
- // Auto-format after append
1224
+ }
1225
+ const hint = suggestions.length > 0
1226
+ ? ' Did you mean: ' + suggestions.slice(0, 3).join(' or ') + '?'
1227
+ : ' Double-check the path against detect_project() output (layoutFile, globalStyleFile) or projectTree.';
1228
+ response.error = 'File not found: "' + message.filePath + '".' + hint;
1229
+ }
1230
+ else {
1231
+ const content = fs.readFileSync(filePath, 'utf-8');
1232
+ // Snapshot before edit
1233
+ undoStack.push({ filePath, prevContent: content, operation: 'EDIT_FILE', timestamp: Date.now(), relativePath: message.filePath });
1234
+ if (undoStack.length > 20)
1235
+ undoStack.shift();
1236
+ // Use the 9-layer fuzzy replacer cascade (ported from OpenCode)
1237
+ const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
1238
+ if ('error' in result) {
1239
+ // Edit failed — remove the snapshot we just pushed
1240
+ undoStack.pop();
1241
+ response.error = result.error;
1242
+ }
1243
+ else {
1244
+ fs.writeFileSync(filePath, result.result, 'utf-8');
1245
+ // Auto-format after edit
1013
1246
  const formatter = await autoFormat(filePath, projectPath);
1014
- response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1015
- console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
1247
+ response.data = {
1248
+ success: true,
1249
+ path: filePath,
1250
+ strategy: result.strategy,
1251
+ formatted: formatter,
1252
+ undoAvailable: true,
1253
+ };
1254
+ const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
1255
+ const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1256
+ console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
1016
1257
  }
1017
1258
  }
1018
- catch (e) {
1019
- response.error = e.message;
1259
+ }
1260
+ catch (e) {
1261
+ response.error = e.message;
1262
+ }
1263
+ break;
1264
+ case 'REPLACE_LINES':
1265
+ try {
1266
+ const filePath = path.resolve(projectPath, message.filePath);
1267
+ if (!filePath.startsWith(projectPath)) {
1268
+ response.error = 'Access denied: Path outside project';
1269
+ }
1270
+ else if (!fs.existsSync(filePath)) {
1271
+ response.error = 'File not found: ' + message.filePath;
1020
1272
  }
1021
- break;
1022
- case 'EDIT_FILE':
1023
- try {
1024
- const filePath = path.resolve(projectPath, message.filePath);
1025
- if (!filePath.startsWith(projectPath)) {
1026
- response.error = 'Access denied: Path outside project';
1273
+ else {
1274
+ const content = fs.readFileSync(filePath, 'utf-8');
1275
+ const lines = content.split('\n');
1276
+ const startLine = Math.max(1, message.startLine || 1);
1277
+ const endLine = Math.min(lines.length, message.endLine || startLine);
1278
+ if (startLine > lines.length) {
1279
+ response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
1027
1280
  }
1028
- else if (!fs.existsSync(filePath)) {
1029
- response.error = 'File not found for editing';
1281
+ else if (startLine > endLine) {
1282
+ response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
1030
1283
  }
1031
1284
  else {
1032
- const content = fs.readFileSync(filePath, 'utf-8');
1033
- // Snapshot before edit
1034
- undoStack.push({ filePath, prevContent: content, operation: 'EDIT_FILE', timestamp: Date.now(), relativePath: message.filePath });
1285
+ // Snapshot for undo
1286
+ undoStack.push({
1287
+ filePath,
1288
+ prevContent: content,
1289
+ operation: 'REPLACE_LINES',
1290
+ timestamp: Date.now(),
1291
+ relativePath: message.filePath
1292
+ });
1035
1293
  if (undoStack.length > 20)
1036
1294
  undoStack.shift();
1037
- // Use the 9-layer fuzzy replacer cascade (ported from OpenCode)
1038
- const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
1039
- if ('error' in result) {
1040
- // Edit failed remove the snapshot we just pushed
1041
- undoStack.pop();
1042
- response.error = result.error;
1043
- }
1044
- else {
1045
- fs.writeFileSync(filePath, result.result, 'utf-8');
1046
- // Auto-format after edit
1047
- const formatter = await autoFormat(filePath, projectPath);
1048
- response.data = {
1049
- success: true,
1050
- path: filePath,
1051
- strategy: result.strategy,
1052
- formatted: formatter,
1053
- undoAvailable: true,
1054
- };
1055
- const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
1056
- const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1057
- console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
1058
- }
1295
+ const oldLineCount = endLine - startLine + 1;
1296
+ const newLines = (message.newContent || '').split('\n');
1297
+ // splice: remove old range, insert new lines
1298
+ lines.splice(startLine - 1, oldLineCount, ...newLines);
1299
+ const newContent = lines.join('\n');
1300
+ fs.writeFileSync(filePath, newContent, 'utf-8');
1301
+ // Auto-format
1302
+ const formatter = await autoFormat(filePath, projectPath);
1303
+ const lineDelta = newLines.length - oldLineCount;
1304
+ response.data = {
1305
+ success: true,
1306
+ path: filePath,
1307
+ linesReplaced: `${startLine}-${endLine}`,
1308
+ oldLineCount,
1309
+ newLineCount: newLines.length,
1310
+ lineDelta: lineDelta,
1311
+ totalLines: lines.length,
1312
+ formatted: formatter,
1313
+ undoAvailable: true,
1314
+ hint: lineDelta !== 0
1315
+ ? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
1316
+ : null
1317
+ };
1318
+ const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1319
+ console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
1059
1320
  }
1060
1321
  }
1061
- catch (e) {
1062
- response.error = e.message;
1322
+ }
1323
+ catch (e) {
1324
+ response.error = e.message;
1325
+ }
1326
+ break;
1327
+ case 'UNDO_LAST':
1328
+ try {
1329
+ if (undoStack.length === 0) {
1330
+ response.error = 'Nothing to undo — no file changes recorded in this session.';
1063
1331
  }
1064
- break;
1065
- case 'REPLACE_LINES':
1066
- try {
1067
- const filePath = path.resolve(projectPath, message.filePath);
1068
- if (!filePath.startsWith(projectPath)) {
1069
- response.error = 'Access denied: Path outside project';
1070
- }
1071
- else if (!fs.existsSync(filePath)) {
1072
- response.error = 'File not found: ' + message.filePath;
1073
- }
1074
- else {
1075
- const content = fs.readFileSync(filePath, 'utf-8');
1076
- const lines = content.split('\n');
1077
- const startLine = Math.max(1, message.startLine || 1);
1078
- const endLine = Math.min(lines.length, message.endLine || startLine);
1079
- if (startLine > lines.length) {
1080
- response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
1081
- }
1082
- else if (startLine > endLine) {
1083
- response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
1084
- }
1085
- else {
1086
- // Snapshot for undo
1087
- undoStack.push({
1088
- filePath,
1089
- prevContent: content,
1090
- operation: 'REPLACE_LINES',
1091
- timestamp: Date.now(),
1092
- relativePath: message.filePath
1093
- });
1094
- if (undoStack.length > 20)
1095
- undoStack.shift();
1096
- const oldLineCount = endLine - startLine + 1;
1097
- const newLines = (message.newContent || '').split('\n');
1098
- // splice: remove old range, insert new lines
1099
- lines.splice(startLine - 1, oldLineCount, ...newLines);
1100
- const newContent = lines.join('\n');
1101
- fs.writeFileSync(filePath, newContent, 'utf-8');
1102
- // Auto-format
1103
- const formatter = await autoFormat(filePath, projectPath);
1104
- const lineDelta = newLines.length - oldLineCount;
1332
+ else {
1333
+ const snapshot = undoStack.pop();
1334
+ if (snapshot.prevContent === null) {
1335
+ // File was created from scratch — delete it
1336
+ if (fs.existsSync(snapshot.filePath)) {
1337
+ fs.unlinkSync(snapshot.filePath);
1105
1338
  response.data = {
1106
1339
  success: true,
1107
- path: filePath,
1108
- linesReplaced: `${startLine}-${endLine}`,
1109
- oldLineCount,
1110
- newLineCount: newLines.length,
1111
- lineDelta: lineDelta,
1112
- totalLines: lines.length,
1113
- formatted: formatter,
1114
- undoAvailable: true,
1115
- hint: lineDelta !== 0
1116
- ? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
1117
- : null
1340
+ undone: snapshot.operation,
1341
+ file: snapshot.relativePath,
1342
+ action: 'deleted (was new file)',
1343
+ remaining: undoStack.length,
1118
1344
  };
1119
- const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1120
- console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
1121
- }
1122
- }
1123
- }
1124
- catch (e) {
1125
- response.error = e.message;
1126
- }
1127
- break;
1128
- case 'UNDO_LAST':
1129
- try {
1130
- if (undoStack.length === 0) {
1131
- response.error = 'Nothing to undo — no file changes recorded in this session.';
1132
- }
1133
- else {
1134
- const snapshot = undoStack.pop();
1135
- if (snapshot.prevContent === null) {
1136
- // File was created from scratch — delete it
1137
- if (fs.existsSync(snapshot.filePath)) {
1138
- fs.unlinkSync(snapshot.filePath);
1139
- response.data = {
1140
- success: true,
1141
- undone: snapshot.operation,
1142
- file: snapshot.relativePath,
1143
- action: 'deleted (was new file)',
1144
- remaining: undoStack.length,
1145
- };
1146
- console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
1147
- }
1148
- else {
1149
- response.data = {
1150
- success: true,
1151
- undone: snapshot.operation,
1152
- file: snapshot.relativePath,
1153
- action: 'already gone',
1154
- remaining: undoStack.length,
1155
- };
1156
- }
1345
+ console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
1157
1346
  }
1158
1347
  else {
1159
- // Restore previous content
1160
- fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
1161
1348
  response.data = {
1162
1349
  success: true,
1163
1350
  undone: snapshot.operation,
1164
1351
  file: snapshot.relativePath,
1165
- action: 'restored',
1352
+ action: 'already gone',
1166
1353
  remaining: undoStack.length,
1167
1354
  };
1168
- console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
1169
- }
1170
- }
1171
- }
1172
- catch (e) {
1173
- response.error = e.message;
1174
- }
1175
- break;
1176
- // ========== GIT & ENVIRONMENT TOOLS ==========
1177
- case 'GIT_BLAME':
1178
- try {
1179
- const filePath = message.filePath;
1180
- const line = message.line;
1181
- const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
1182
- const lines = stdout.split('\n');
1183
- const commitHash = lines[0].split(' ')[0];
1184
- const author = lines.find((l) => l.startsWith('author '))?.substring(7);
1185
- const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
1186
- const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
1187
- const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
1188
- response.data = {
1189
- commit: commitHash,
1190
- author,
1191
- email,
1192
- date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
1193
- message: summary
1194
- };
1195
- }
1196
- catch (e) {
1197
- response.error = `Git blame failed: ${e.message}`;
1198
- }
1199
- break;
1200
- case 'GIT_RECENT_CHANGES':
1201
- try {
1202
- const filePath = message.filePath;
1203
- const days = message.days || 7;
1204
- const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
1205
- response.data = {
1206
- history: stdout.split('\n').filter(Boolean).map((line) => {
1207
- const [hash, author, date, message] = line.split('|');
1208
- return { hash, author, date, message };
1209
- })
1210
- };
1211
- }
1212
- catch (e) {
1213
- response.error = `Git log failed: ${e.message}`;
1214
- }
1215
- break;
1216
- case 'GET_IMPORTS':
1217
- try {
1218
- const filePath = path.resolve(projectPath, message.filePath);
1219
- if (fs.existsSync(filePath)) {
1220
- const content = fs.readFileSync(filePath, 'utf-8');
1221
- const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
1222
- const imports = [];
1223
- let match;
1224
- while ((match = importRegex.exec(content)) !== null) {
1225
- imports.push(match[1]);
1226
1355
  }
1227
- response.data = { imports };
1228
1356
  }
1229
1357
  else {
1230
- response.error = 'File not found';
1358
+ // Restore previous content
1359
+ fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
1360
+ response.data = {
1361
+ success: true,
1362
+ undone: snapshot.operation,
1363
+ file: snapshot.relativePath,
1364
+ action: 'restored',
1365
+ remaining: undoStack.length,
1366
+ };
1367
+ console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
1231
1368
  }
1232
1369
  }
1233
- catch (e) {
1234
- response.error = e.message;
1235
- }
1236
- break;
1237
- case 'FIND_USAGES':
1238
- try {
1239
- const query = message.query;
1240
- const { stdout } = await execAsync(`git grep -n "${query}"`, { cwd: projectPath });
1241
- response.data = {
1242
- usages: stdout.split('\n').filter(Boolean).slice(0, 20).map((line) => {
1243
- const parts = line.split(':');
1244
- return {
1245
- file: parts[0],
1246
- line: parseInt(parts[1]),
1247
- content: parts.slice(2).join(':').trim()
1248
- };
1249
- })
1250
- };
1370
+ }
1371
+ catch (e) {
1372
+ response.error = e.message;
1373
+ }
1374
+ break;
1375
+ // ========== GIT & ENVIRONMENT TOOLS ==========
1376
+ case 'GIT_BLAME':
1377
+ try {
1378
+ const filePath = message.filePath;
1379
+ const line = message.line;
1380
+ const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
1381
+ const lines = stdout.split('\n');
1382
+ const commitHash = lines[0].split(' ')[0];
1383
+ const author = lines.find((l) => l.startsWith('author '))?.substring(7);
1384
+ const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
1385
+ const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
1386
+ const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
1387
+ response.data = {
1388
+ commit: commitHash,
1389
+ author,
1390
+ email,
1391
+ date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
1392
+ message: summary
1393
+ };
1394
+ }
1395
+ catch (e) {
1396
+ response.error = `Git blame failed: ${e.message}`;
1397
+ }
1398
+ break;
1399
+ case 'GIT_RECENT_CHANGES':
1400
+ try {
1401
+ const filePath = message.filePath;
1402
+ const days = message.days || 7;
1403
+ const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
1404
+ response.data = {
1405
+ history: stdout.split('\n').filter(Boolean).map((line) => {
1406
+ const [hash, author, date, message] = line.split('|');
1407
+ return { hash, author, date, message };
1408
+ })
1409
+ };
1410
+ }
1411
+ catch (e) {
1412
+ response.error = `Git log failed: ${e.message}`;
1413
+ }
1414
+ break;
1415
+ case 'GET_IMPORTS':
1416
+ try {
1417
+ const filePath = path.resolve(projectPath, message.filePath);
1418
+ if (fs.existsSync(filePath)) {
1419
+ const content = fs.readFileSync(filePath, 'utf-8');
1420
+ const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
1421
+ const imports = [];
1422
+ let match;
1423
+ while ((match = importRegex.exec(content)) !== null) {
1424
+ imports.push(match[1]);
1425
+ }
1426
+ response.data = { imports };
1251
1427
  }
1252
- catch (e) {
1253
- if (e.code === 1)
1254
- response.data = { usages: [] };
1255
- else
1256
- response.error = `Grep failed: ${e.message}`;
1428
+ else {
1429
+ response.error = 'File not found';
1257
1430
  }
1258
- break;
1259
- case 'GET_ENV_VARS':
1260
- try {
1261
- const filePath = path.resolve(projectPath, message.filePath);
1262
- if (fs.existsSync(filePath)) {
1263
- const content = fs.readFileSync(filePath, 'utf-8');
1264
- const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
1265
- const vars = new Set();
1266
- let match;
1267
- while ((match = envRegex.exec(content)) !== null) {
1268
- vars.add(match[1]);
1269
- }
1270
- response.data = { envVars: Array.from(vars) };
1271
- }
1272
- else {
1273
- response.error = 'File not found';
1431
+ }
1432
+ catch (e) {
1433
+ response.error = e.message;
1434
+ }
1435
+ break;
1436
+ case 'FIND_USAGES':
1437
+ try {
1438
+ const query = message.query;
1439
+ const { stdout } = await execAsync(`git grep -n "${query}"`, { cwd: projectPath });
1440
+ response.data = {
1441
+ usages: stdout.split('\n').filter(Boolean).slice(0, 20).map((line) => {
1442
+ const parts = line.split(':');
1443
+ return {
1444
+ file: parts[0],
1445
+ line: parseInt(parts[1]),
1446
+ content: parts.slice(2).join(':').trim()
1447
+ };
1448
+ })
1449
+ };
1450
+ }
1451
+ catch (e) {
1452
+ if (e.code === 1)
1453
+ response.data = { usages: [] };
1454
+ else
1455
+ response.error = `Grep failed: ${e.message}`;
1456
+ }
1457
+ break;
1458
+ case 'GET_ENV_VARS':
1459
+ try {
1460
+ const filePath = path.resolve(projectPath, message.filePath);
1461
+ if (fs.existsSync(filePath)) {
1462
+ const content = fs.readFileSync(filePath, 'utf-8');
1463
+ const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
1464
+ const vars = new Set();
1465
+ let match;
1466
+ while ((match = envRegex.exec(content)) !== null) {
1467
+ vars.add(match[1]);
1274
1468
  }
1469
+ response.data = { envVars: Array.from(vars) };
1275
1470
  }
1276
- catch (e) {
1277
- response.error = e.message;
1471
+ else {
1472
+ response.error = 'File not found';
1278
1473
  }
1279
- break;
1280
- default:
1281
- response.error = `Unknown message type: ${type}`;
1474
+ }
1475
+ catch (e) {
1476
+ response.error = e.message;
1477
+ }
1478
+ break;
1479
+ // ========== TERMINAL BUFFER ACCESS ==========
1480
+ case 'GET_TERMINAL_BUFFER': {
1481
+ // Returns the last N lines of the dev server terminal output.
1482
+ // Agents use this to get compiler errors without running a build.
1483
+ const lines = parseInt(message.lines) || 100;
1484
+ const buffer = globalTerminalBuffer.getLast(lines);
1485
+ response.data = {
1486
+ lines: buffer,
1487
+ totalLines: globalTerminalBuffer.length,
1488
+ hasDevProcess: devProcess !== null && !devProcess.killed,
1489
+ };
1490
+ break;
1282
1491
  }
1283
- ws.send(JSON.stringify(response));
1284
- }
1285
- catch (e) {
1286
- console.error(chalk.red('Parse error:'), e.message);
1492
+ case 'GET_DEV_STATUS': {
1493
+ response.data = {
1494
+ running: devProcess !== null && !devProcess.killed,
1495
+ pid: devProcess?.pid ?? null,
1496
+ };
1497
+ break;
1498
+ }
1499
+ default:
1500
+ response.error = `Unknown message type: ${type}`;
1287
1501
  }
1288
- });
1502
+ ws.send(JSON.stringify(response));
1503
+ }
1504
+ catch (e) {
1505
+ console.error(chalk.red('Parse error:'), e.message);
1506
+ }
1507
+ });
1508
+ }
1509
+ // ============================================
1510
+ // COMMAND: trace connect (legacy / IDE-only)
1511
+ // ============================================
1512
+ program
1513
+ .command('connect', { isDefault: true })
1514
+ .alias('c')
1515
+ .description('Start IDE bridge only (use "trace dev" to also start your dev server)')
1516
+ .option('-p, --port <port>', 'WebSocket port', '8765')
1517
+ .action(async (options) => {
1518
+ const port = parseInt(options.port);
1519
+ const projectPath = process.cwd();
1520
+ console.log();
1521
+ console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
1522
+ console.log(chalk.gray('─'.repeat(55)));
1523
+ console.log();
1524
+ console.log(`Project: ${chalk.green(projectPath)}`);
1525
+ console.log(`Port: ${chalk.cyan(port)}`);
1526
+ console.log();
1527
+ try {
1528
+ const pkgPath = path.join(projectPath, 'package.json');
1529
+ if (fs.existsSync(pkgPath)) {
1530
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1531
+ console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
1532
+ }
1533
+ }
1534
+ catch (_) { }
1535
+ const wss = new WebSocketServer({ port });
1536
+ let clientCount = 0;
1537
+ console.log();
1538
+ console.log(chalk.green('✓') + ' WebSocket server started');
1539
+ console.log(chalk.dim('Waiting for extension to connect...'));
1540
+ console.log();
1541
+ console.log(chalk.gray('─'.repeat(55)));
1542
+ console.log(chalk.dim('Press Ctrl+C to stop'));
1543
+ console.log();
1544
+ wss.on('connection', (ws) => {
1545
+ clientCount++;
1546
+ console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
1547
+ // Wire up the full IDE bridge protocol
1548
+ attachMessageHandler(ws, projectPath);
1289
1549
  ws.on('close', () => {
1290
1550
  clientCount--;
1291
1551
  console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);