@gettrace/cli 1.2.6 → 1.2.8

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