@eyeglass/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { execFileSync } from 'child_process';
5
+ // ============================================================================
6
+ // Templates
7
+ // ============================================================================
8
+ const VITE_PLUGIN = `// Eyeglass Vite Plugin - Auto-generated by eyeglass init
9
+ export function eyeglassPlugin() {
10
+ return {
11
+ name: 'eyeglass',
12
+ transformIndexHtml(html) {
13
+ if (process.env.NODE_ENV === 'production') return html;
14
+ return html.replace(
15
+ '</body>',
16
+ '<script type="module">import "@eyeglass/inspector";</script></body>'
17
+ );
18
+ },
19
+ };
20
+ }
21
+ `;
22
+ const NEXT_CONFIG_ADDITION = `
23
+ // Eyeglass configuration - Auto-generated by eyeglass init
24
+ const withEyeglass = (nextConfig) => {
25
+ if (process.env.NODE_ENV === 'production') return nextConfig;
26
+ return {
27
+ ...nextConfig,
28
+ webpack: (config, options) => {
29
+ if (!options.isServer) {
30
+ config.module.rules.push({
31
+ test: /node_modules\\/@eyeglass\\/inspector/,
32
+ sideEffects: true,
33
+ });
34
+ }
35
+ if (typeof nextConfig.webpack === 'function') {
36
+ return nextConfig.webpack(config, options);
37
+ }
38
+ return config;
39
+ },
40
+ };
41
+ };
42
+ `;
43
+ const CLAUDE_CONFIG = {
44
+ mcpServers: {
45
+ eyeglass: {
46
+ command: 'npx',
47
+ args: ['eyeglass-bridge'],
48
+ },
49
+ },
50
+ };
51
+ // ============================================================================
52
+ // Utilities
53
+ // ============================================================================
54
+ function log(message, type = 'info') {
55
+ const prefix = {
56
+ info: '\x1b[36m→\x1b[0m',
57
+ success: '\x1b[32m✓\x1b[0m',
58
+ warn: '\x1b[33m⚠\x1b[0m',
59
+ error: '\x1b[31m✗\x1b[0m',
60
+ };
61
+ console.log(`${prefix[type]} ${message}`);
62
+ }
63
+ function fileExists(filePath) {
64
+ return fs.existsSync(filePath);
65
+ }
66
+ function readFile(filePath) {
67
+ return fs.readFileSync(filePath, 'utf-8');
68
+ }
69
+ function writeFile(filePath, content) {
70
+ fs.writeFileSync(filePath, content, 'utf-8');
71
+ }
72
+ function detectPackageManager() {
73
+ const cwd = process.cwd();
74
+ if (fileExists(path.join(cwd, 'bun.lockb')))
75
+ return 'bun';
76
+ if (fileExists(path.join(cwd, 'pnpm-lock.yaml')))
77
+ return 'pnpm';
78
+ if (fileExists(path.join(cwd, 'yarn.lock')))
79
+ return 'yarn';
80
+ return 'npm';
81
+ }
82
+ function installPackage(packageName, dev = true) {
83
+ const pm = detectPackageManager();
84
+ const devFlag = {
85
+ npm: dev ? '--save-dev' : '--save',
86
+ yarn: dev ? '--dev' : '',
87
+ pnpm: dev ? '--save-dev' : '',
88
+ bun: dev ? '--dev' : '',
89
+ };
90
+ const installCmd = {
91
+ npm: 'install',
92
+ yarn: 'add',
93
+ pnpm: 'add',
94
+ bun: 'add',
95
+ };
96
+ log(`Installing ${packageName} with ${pm}...`);
97
+ try {
98
+ const args = [installCmd[pm], packageName];
99
+ if (devFlag[pm]) {
100
+ args.push(devFlag[pm]);
101
+ }
102
+ execFileSync(pm, args, { stdio: 'pipe', cwd: process.cwd() });
103
+ return true;
104
+ }
105
+ catch (error) {
106
+ return false;
107
+ }
108
+ }
109
+ function detectProject() {
110
+ const cwd = process.cwd();
111
+ const hasTs = fileExists(path.join(cwd, 'tsconfig.json'));
112
+ // Vite
113
+ const viteConfigTs = path.join(cwd, 'vite.config.ts');
114
+ const viteConfigJs = path.join(cwd, 'vite.config.js');
115
+ if (fileExists(viteConfigTs)) {
116
+ return { type: 'vite', configFile: viteConfigTs, typescript: true };
117
+ }
118
+ if (fileExists(viteConfigJs)) {
119
+ return { type: 'vite', configFile: viteConfigJs, typescript: hasTs };
120
+ }
121
+ // Next.js
122
+ const nextConfigs = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
123
+ for (const config of nextConfigs) {
124
+ const configPath = path.join(cwd, config);
125
+ if (fileExists(configPath)) {
126
+ return { type: 'next', configFile: configPath, typescript: hasTs };
127
+ }
128
+ }
129
+ // Remix
130
+ const remixConfig = path.join(cwd, 'remix.config.js');
131
+ if (fileExists(remixConfig)) {
132
+ return { type: 'remix', configFile: remixConfig, typescript: hasTs };
133
+ }
134
+ // CRA
135
+ const pkgPath = path.join(cwd, 'package.json');
136
+ if (fileExists(pkgPath)) {
137
+ const pkg = JSON.parse(readFile(pkgPath));
138
+ if (pkg.dependencies?.['react-scripts']) {
139
+ // Find entry file
140
+ const entryFiles = ['src/index.tsx', 'src/index.ts', 'src/index.jsx', 'src/index.js'];
141
+ for (const entry of entryFiles) {
142
+ if (fileExists(path.join(cwd, entry))) {
143
+ return { type: 'cra', entryFile: path.join(cwd, entry), typescript: hasTs };
144
+ }
145
+ }
146
+ return { type: 'cra', typescript: hasTs };
147
+ }
148
+ }
149
+ return { type: 'unknown', typescript: hasTs };
150
+ }
151
+ // ============================================================================
152
+ // Config Modifiers
153
+ // ============================================================================
154
+ function setupClaudeConfig(dryRun) {
155
+ const cwd = process.cwd();
156
+ const claudeDir = path.join(cwd, '.claude');
157
+ const configPath = path.join(claudeDir, 'settings.json');
158
+ if (fileExists(configPath)) {
159
+ // Check if eyeglass is already configured
160
+ try {
161
+ const existing = JSON.parse(readFile(configPath));
162
+ if (existing.mcpServers?.eyeglass) {
163
+ log('Claude Code MCP config already exists', 'success');
164
+ return true;
165
+ }
166
+ // Merge with existing config
167
+ const merged = {
168
+ ...existing,
169
+ mcpServers: {
170
+ ...existing.mcpServers,
171
+ ...CLAUDE_CONFIG.mcpServers,
172
+ },
173
+ };
174
+ if (dryRun) {
175
+ log(`Would update ${configPath} with eyeglass MCP config`);
176
+ }
177
+ else {
178
+ writeFile(configPath, JSON.stringify(merged, null, 2));
179
+ log('Added eyeglass to existing Claude Code config', 'success');
180
+ }
181
+ return true;
182
+ }
183
+ catch {
184
+ log('Could not parse existing .claude/settings.json', 'warn');
185
+ return false;
186
+ }
187
+ }
188
+ if (dryRun) {
189
+ log(`Would create ${configPath}`);
190
+ }
191
+ else {
192
+ if (!fileExists(claudeDir)) {
193
+ fs.mkdirSync(claudeDir, { recursive: true });
194
+ }
195
+ writeFile(configPath, JSON.stringify(CLAUDE_CONFIG, null, 2));
196
+ log('Created .claude/settings.json with MCP server config', 'success');
197
+ }
198
+ return true;
199
+ }
200
+ function setupVite(configFile, dryRun) {
201
+ const cwd = process.cwd();
202
+ const isTs = configFile.endsWith('.ts');
203
+ const pluginFile = path.join(cwd, isTs ? 'eyeglass.plugin.ts' : 'eyeglass.plugin.js');
204
+ // Create plugin file
205
+ if (!fileExists(pluginFile)) {
206
+ if (dryRun) {
207
+ log(`Would create ${path.basename(pluginFile)}`);
208
+ }
209
+ else {
210
+ writeFile(pluginFile, VITE_PLUGIN);
211
+ log(`Created ${path.basename(pluginFile)}`, 'success');
212
+ }
213
+ }
214
+ // Modify vite.config
215
+ const config = readFile(configFile);
216
+ // Check if already configured
217
+ if (config.includes('eyeglassPlugin')) {
218
+ log('Vite config already has eyeglass plugin', 'success');
219
+ return true;
220
+ }
221
+ // Add import
222
+ const importStatement = `import { eyeglassPlugin } from './eyeglass.plugin';\n`;
223
+ let newConfig = config;
224
+ // Find a good place to add the import (after other imports or at the top)
225
+ const lastImportMatch = config.match(/^import .+$/gm);
226
+ if (lastImportMatch) {
227
+ const lastImport = lastImportMatch[lastImportMatch.length - 1];
228
+ const lastImportIndex = config.lastIndexOf(lastImport) + lastImport.length;
229
+ newConfig = config.slice(0, lastImportIndex) + '\n' + importStatement + config.slice(lastImportIndex);
230
+ }
231
+ else {
232
+ newConfig = importStatement + config;
233
+ }
234
+ // Add to plugins array
235
+ // This handles: plugins: [...] or plugins: [react(), ...]
236
+ const pluginsMatch = newConfig.match(/plugins\s*:\s*\[/);
237
+ if (pluginsMatch) {
238
+ const insertPos = newConfig.indexOf(pluginsMatch[0]) + pluginsMatch[0].length;
239
+ newConfig = newConfig.slice(0, insertPos) + 'eyeglassPlugin(), ' + newConfig.slice(insertPos);
240
+ }
241
+ else {
242
+ log('Could not find plugins array in vite config - please add eyeglassPlugin() manually', 'warn');
243
+ return false;
244
+ }
245
+ if (dryRun) {
246
+ log(`Would modify ${path.basename(configFile)} to add eyeglassPlugin()`);
247
+ }
248
+ else {
249
+ writeFile(configFile, newConfig);
250
+ log(`Updated ${path.basename(configFile)} with eyeglass plugin`, 'success');
251
+ }
252
+ return true;
253
+ }
254
+ function setupNext(configFile, dryRun) {
255
+ const cwd = process.cwd();
256
+ const config = readFile(configFile);
257
+ // Check if already configured
258
+ if (config.includes('@eyeglass/inspector')) {
259
+ log('Next.js config already has eyeglass', 'success');
260
+ return true;
261
+ }
262
+ // For Next.js, we'll add the inspector import to the layout or _app file
263
+ const layoutFiles = [
264
+ 'app/layout.tsx',
265
+ 'app/layout.jsx',
266
+ 'src/app/layout.tsx',
267
+ 'src/app/layout.jsx',
268
+ 'pages/_app.tsx',
269
+ 'pages/_app.jsx',
270
+ 'src/pages/_app.tsx',
271
+ 'src/pages/_app.jsx',
272
+ ];
273
+ let targetFile = null;
274
+ for (const file of layoutFiles) {
275
+ const fullPath = path.join(cwd, file);
276
+ if (fileExists(fullPath)) {
277
+ targetFile = fullPath;
278
+ break;
279
+ }
280
+ }
281
+ if (!targetFile) {
282
+ log('Could not find layout.tsx or _app.tsx - please import @eyeglass/inspector manually', 'warn');
283
+ return false;
284
+ }
285
+ const fileContent = readFile(targetFile);
286
+ // Check if already imported
287
+ if (fileContent.includes('@eyeglass/inspector')) {
288
+ log('Inspector already imported in ' + path.basename(targetFile), 'success');
289
+ return true;
290
+ }
291
+ // Add import at the top (after 'use client' if present)
292
+ const importStatement = `import '@eyeglass/inspector';\n`;
293
+ let newContent;
294
+ if (fileContent.startsWith("'use client'") || fileContent.startsWith('"use client"')) {
295
+ const firstLineEnd = fileContent.indexOf('\n') + 1;
296
+ newContent = fileContent.slice(0, firstLineEnd) + importStatement + fileContent.slice(firstLineEnd);
297
+ }
298
+ else {
299
+ newContent = importStatement + fileContent;
300
+ }
301
+ if (dryRun) {
302
+ log(`Would add inspector import to ${path.relative(cwd, targetFile)}`);
303
+ }
304
+ else {
305
+ writeFile(targetFile, newContent);
306
+ log(`Added inspector import to ${path.relative(cwd, targetFile)}`, 'success');
307
+ }
308
+ return true;
309
+ }
310
+ function setupCRA(entryFile, dryRun) {
311
+ const cwd = process.cwd();
312
+ // Find entry file if not provided
313
+ if (!entryFile) {
314
+ const entryFiles = ['src/index.tsx', 'src/index.ts', 'src/index.jsx', 'src/index.js'];
315
+ for (const entry of entryFiles) {
316
+ const fullPath = path.join(cwd, entry);
317
+ if (fileExists(fullPath)) {
318
+ entryFile = fullPath;
319
+ break;
320
+ }
321
+ }
322
+ }
323
+ if (!entryFile || !fileExists(entryFile)) {
324
+ log('Could not find entry file - please import @eyeglass/inspector manually', 'warn');
325
+ return false;
326
+ }
327
+ const content = readFile(entryFile);
328
+ // Check if already imported
329
+ if (content.includes('@eyeglass/inspector')) {
330
+ log('Inspector already imported', 'success');
331
+ return true;
332
+ }
333
+ // Add import at the top
334
+ const importStatement = `import '@eyeglass/inspector';\n`;
335
+ const newContent = importStatement + content;
336
+ if (dryRun) {
337
+ log(`Would add inspector import to ${path.relative(cwd, entryFile)}`);
338
+ }
339
+ else {
340
+ writeFile(entryFile, newContent);
341
+ log(`Added inspector import to ${path.relative(cwd, entryFile)}`, 'success');
342
+ }
343
+ return true;
344
+ }
345
+ function init(options) {
346
+ const { dryRun, skipInstall } = options;
347
+ console.log('\n\x1b[1m🔍 Eyeglass Setup\x1b[0m\n');
348
+ if (dryRun) {
349
+ log('Running in dry-run mode - no changes will be made\n', 'warn');
350
+ }
351
+ // Detect project
352
+ const project = detectProject();
353
+ log(`Detected: ${project.type}${project.typescript ? ' (TypeScript)' : ''}`);
354
+ // Step 1: Install inspector
355
+ if (!skipInstall) {
356
+ if (dryRun) {
357
+ log('Would install @eyeglass/inspector');
358
+ }
359
+ else {
360
+ const installed = installPackage('@eyeglass/inspector', true);
361
+ if (installed) {
362
+ log('Installed @eyeglass/inspector', 'success');
363
+ }
364
+ else {
365
+ log('Failed to install @eyeglass/inspector - please install manually', 'error');
366
+ }
367
+ }
368
+ }
369
+ // Step 2: Setup Claude Code MCP config
370
+ setupClaudeConfig(dryRun);
371
+ // Step 3: Setup project-specific integration
372
+ console.log('');
373
+ switch (project.type) {
374
+ case 'vite':
375
+ if (project.configFile) {
376
+ setupVite(project.configFile, dryRun);
377
+ }
378
+ break;
379
+ case 'next':
380
+ if (project.configFile) {
381
+ setupNext(project.configFile, dryRun);
382
+ }
383
+ break;
384
+ case 'cra':
385
+ case 'remix':
386
+ setupCRA(project.entryFile, dryRun);
387
+ break;
388
+ default:
389
+ log('Unknown project type - please configure manually:', 'warn');
390
+ console.log(' 1. Import @eyeglass/inspector in your entry file');
391
+ console.log(' 2. Or add <script type="module">import "@eyeglass/inspector";</script> to your HTML\n');
392
+ }
393
+ // Done!
394
+ console.log('\n\x1b[1m✨ Setup complete!\x1b[0m\n');
395
+ console.log('\x1b[1mHow to use Eyeglass:\x1b[0m\n');
396
+ console.log(' 1. Start your dev server');
397
+ console.log(' 2. Run \x1b[36mclaude\x1b[0m in this directory');
398
+ console.log(' 3. Tell Claude: \x1b[36m"watch eyeglass"\x1b[0m or \x1b[36m"eg"\x1b[0m');
399
+ console.log(' → Claude will start listening for requests');
400
+ console.log(' 4. In your browser, hover over any element and click it to select for Eyeglass context. You can multi-select up to 5 items.');
401
+ console.log(' 5. Type your request (e.g., "make this blue") and submit');
402
+ console.log(' → Claude automatically receives it and starts working!\n');
403
+ console.log('\x1b[2mTip: You never need to leave your browser - Claude watches for requests.\x1b[0m\n');
404
+ }
405
+ function help() {
406
+ console.log(`
407
+ \x1b[1mEyeglass\x1b[0m - Visual debugging for AI coding agents
408
+
409
+ Point at UI elements in your browser and tell Claude what to change.
410
+ Claude watches for requests, so you never need to leave your browser.
411
+
412
+ \x1b[1mUSAGE\x1b[0m
413
+ npx @eyeglass/cli <command> [options]
414
+
415
+ \x1b[1mCOMMANDS\x1b[0m
416
+ init Initialize Eyeglass in your project
417
+ help Show this help message
418
+
419
+ \x1b[1mOPTIONS\x1b[0m
420
+ --dry-run Preview changes without making them
421
+ --skip-install Skip installing @eyeglass/inspector
422
+
423
+ \x1b[1mEXAMPLES\x1b[0m
424
+ npx @eyeglass/cli init # Full automatic setup
425
+ npx @eyeglass/cli init --dry-run # Preview what would change
426
+
427
+ \x1b[1mWORKFLOW\x1b[0m
428
+ 1. Run \x1b[36mnpx @eyeglass/cli init\x1b[0m in your project
429
+ 2. Start your dev server
430
+ 3. Run \x1b[36mclaude\x1b[0m and say "watch eyeglass" or "eg"
431
+ 4. In your browser: hover over element → press E → type request
432
+ 5. Claude automatically picks up requests and makes changes!
433
+
434
+ \x1b[1mMORE INFO\x1b[0m
435
+ https://github.com/donutboyband/eyeglass
436
+ `);
437
+ }
438
+ // ============================================================================
439
+ // Main
440
+ // ============================================================================
441
+ const args = process.argv.slice(2);
442
+ const command = args.find((arg) => !arg.startsWith('-'));
443
+ const flags = args.filter((arg) => arg.startsWith('-'));
444
+ const options = {
445
+ dryRun: flags.includes('--dry-run'),
446
+ skipInstall: flags.includes('--skip-install'),
447
+ };
448
+ switch (command) {
449
+ case 'init':
450
+ init(options);
451
+ break;
452
+ case 'help':
453
+ case undefined:
454
+ help();
455
+ break;
456
+ default:
457
+ if (command) {
458
+ console.error(`Unknown command: ${command}\n`);
459
+ }
460
+ help();
461
+ process.exit(command ? 1 : 0);
462
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as path from 'path';
3
+ // Mock fs module
4
+ vi.mock('fs', () => ({
5
+ existsSync: vi.fn(),
6
+ readFileSync: vi.fn(),
7
+ writeFileSync: vi.fn(),
8
+ mkdirSync: vi.fn(),
9
+ }));
10
+ // Mock child_process
11
+ vi.mock('child_process', () => ({
12
+ execSync: vi.fn(),
13
+ spawnSync: vi.fn(),
14
+ }));
15
+ // Helper to recreate detectProject logic for testing
16
+ function detectProject(cwd, mockFs) {
17
+ const hasTs = mockFs.existsSync(path.join(cwd, 'tsconfig.json'));
18
+ // Vite
19
+ if (mockFs.existsSync(path.join(cwd, 'vite.config.ts'))) {
20
+ return { type: 'vite', configFile: path.join(cwd, 'vite.config.ts'), typescript: true };
21
+ }
22
+ if (mockFs.existsSync(path.join(cwd, 'vite.config.js'))) {
23
+ return { type: 'vite', configFile: path.join(cwd, 'vite.config.js'), typescript: hasTs };
24
+ }
25
+ // Next.js
26
+ const nextConfigs = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
27
+ for (const config of nextConfigs) {
28
+ if (mockFs.existsSync(path.join(cwd, config))) {
29
+ return { type: 'next', configFile: path.join(cwd, config), typescript: hasTs };
30
+ }
31
+ }
32
+ // Remix
33
+ if (mockFs.existsSync(path.join(cwd, 'remix.config.js'))) {
34
+ return { type: 'remix', configFile: path.join(cwd, 'remix.config.js'), typescript: hasTs };
35
+ }
36
+ // CRA
37
+ const pkgPath = path.join(cwd, 'package.json');
38
+ if (mockFs.existsSync(pkgPath)) {
39
+ try {
40
+ const pkg = JSON.parse(mockFs.readFileSync(pkgPath));
41
+ if (pkg.dependencies?.['react-scripts']) {
42
+ return { type: 'cra', typescript: hasTs };
43
+ }
44
+ }
45
+ catch {
46
+ // Invalid JSON
47
+ }
48
+ }
49
+ return { type: 'unknown', typescript: hasTs };
50
+ }
51
+ // Helper to recreate detectPackageManager logic
52
+ function detectPackageManager(cwd, mockFs) {
53
+ if (mockFs.existsSync(path.join(cwd, 'bun.lockb')))
54
+ return 'bun';
55
+ if (mockFs.existsSync(path.join(cwd, 'pnpm-lock.yaml')))
56
+ return 'pnpm';
57
+ if (mockFs.existsSync(path.join(cwd, 'yarn.lock')))
58
+ return 'yarn';
59
+ return 'npm';
60
+ }
61
+ describe('eyeglass CLI', () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ });
65
+ describe('detectProject', () => {
66
+ it('should detect Vite TypeScript project', () => {
67
+ const mockFs = {
68
+ existsSync: (p) => p.endsWith('vite.config.ts') || p.endsWith('tsconfig.json'),
69
+ readFileSync: () => '',
70
+ };
71
+ const result = detectProject('/my/project', mockFs);
72
+ expect(result.type).toBe('vite');
73
+ expect(result.typescript).toBe(true);
74
+ expect(result.configFile).toContain('vite.config.ts');
75
+ });
76
+ it('should detect Vite JavaScript project', () => {
77
+ const mockFs = {
78
+ existsSync: (p) => p.endsWith('vite.config.js'),
79
+ readFileSync: () => '',
80
+ };
81
+ const result = detectProject('/my/project', mockFs);
82
+ expect(result.type).toBe('vite');
83
+ expect(result.configFile).toContain('vite.config.js');
84
+ });
85
+ it('should detect Next.js from next.config.mjs', () => {
86
+ const mockFs = {
87
+ existsSync: (p) => p.endsWith('next.config.mjs'),
88
+ readFileSync: () => '',
89
+ };
90
+ const result = detectProject('/my/project', mockFs);
91
+ expect(result.type).toBe('next');
92
+ expect(result.configFile).toContain('next.config.mjs');
93
+ });
94
+ it('should detect Next.js from next.config.ts', () => {
95
+ const mockFs = {
96
+ existsSync: (p) => p.endsWith('next.config.ts') || p.endsWith('tsconfig.json'),
97
+ readFileSync: () => '',
98
+ };
99
+ const result = detectProject('/my/project', mockFs);
100
+ expect(result.type).toBe('next');
101
+ expect(result.typescript).toBe(true);
102
+ });
103
+ it('should detect Remix project', () => {
104
+ const mockFs = {
105
+ existsSync: (p) => p.endsWith('remix.config.js'),
106
+ readFileSync: () => '',
107
+ };
108
+ const result = detectProject('/my/project', mockFs);
109
+ expect(result.type).toBe('remix');
110
+ });
111
+ it('should detect CRA project', () => {
112
+ const mockFs = {
113
+ existsSync: (p) => p.endsWith('package.json'),
114
+ readFileSync: () => JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
115
+ };
116
+ const result = detectProject('/my/project', mockFs);
117
+ expect(result.type).toBe('cra');
118
+ });
119
+ it('should return unknown for unrecognized project', () => {
120
+ const mockFs = {
121
+ existsSync: () => false,
122
+ readFileSync: () => '',
123
+ };
124
+ const result = detectProject('/my/project', mockFs);
125
+ expect(result.type).toBe('unknown');
126
+ });
127
+ it('should detect TypeScript from tsconfig.json', () => {
128
+ const mockFs = {
129
+ existsSync: (p) => p.endsWith('tsconfig.json'),
130
+ readFileSync: () => '',
131
+ };
132
+ const result = detectProject('/my/project', mockFs);
133
+ expect(result.typescript).toBe(true);
134
+ });
135
+ });
136
+ describe('detectPackageManager', () => {
137
+ it('should detect bun from bun.lockb', () => {
138
+ const mockFs = {
139
+ existsSync: (p) => p.endsWith('bun.lockb'),
140
+ };
141
+ const result = detectPackageManager('/my/project', mockFs);
142
+ expect(result).toBe('bun');
143
+ });
144
+ it('should detect pnpm from pnpm-lock.yaml', () => {
145
+ const mockFs = {
146
+ existsSync: (p) => p.endsWith('pnpm-lock.yaml'),
147
+ };
148
+ const result = detectPackageManager('/my/project', mockFs);
149
+ expect(result).toBe('pnpm');
150
+ });
151
+ it('should detect yarn from yarn.lock', () => {
152
+ const mockFs = {
153
+ existsSync: (p) => p.endsWith('yarn.lock'),
154
+ };
155
+ const result = detectPackageManager('/my/project', mockFs);
156
+ expect(result).toBe('yarn');
157
+ });
158
+ it('should default to npm', () => {
159
+ const mockFs = {
160
+ existsSync: () => false,
161
+ };
162
+ const result = detectPackageManager('/my/project', mockFs);
163
+ expect(result).toBe('npm');
164
+ });
165
+ });
166
+ describe('CLAUDE_CONFIG', () => {
167
+ it('should have correct MCP server structure', () => {
168
+ const config = {
169
+ mcpServers: {
170
+ eyeglass: {
171
+ command: 'npx',
172
+ args: ['eyeglass-bridge'],
173
+ },
174
+ },
175
+ };
176
+ expect(config.mcpServers.eyeglass.command).toBe('npx');
177
+ expect(config.mcpServers.eyeglass.args).toEqual(['eyeglass-bridge']);
178
+ });
179
+ });
180
+ describe('VITE_PLUGIN template', () => {
181
+ it('should contain required plugin structure', () => {
182
+ const plugin = `export function eyeglassPlugin() {
183
+ return {
184
+ name: 'eyeglass',
185
+ transformIndexHtml(html) {
186
+ if (process.env.NODE_ENV === 'production') return html;
187
+ return html.replace(
188
+ '</body>',
189
+ '<script type="module">import "@eyeglass/inspector";</script></body>'
190
+ );
191
+ },
192
+ };
193
+ }`;
194
+ expect(plugin).toContain("name: 'eyeglass'");
195
+ expect(plugin).toContain('transformIndexHtml');
196
+ expect(plugin).toContain('@eyeglass/inspector');
197
+ expect(plugin).toContain("process.env.NODE_ENV === 'production'");
198
+ });
199
+ });
200
+ describe('Vite config modification', () => {
201
+ it('should detect if eyeglassPlugin is already configured', () => {
202
+ const existingConfig = `
203
+ import { defineConfig } from 'vite';
204
+ import { eyeglassPlugin } from './eyeglass.plugin';
205
+
206
+ export default defineConfig({
207
+ plugins: [eyeglassPlugin()],
208
+ });
209
+ `;
210
+ expect(existingConfig.includes('eyeglassPlugin')).toBe(true);
211
+ });
212
+ it('should identify plugins array location', () => {
213
+ const config = `export default defineConfig({
214
+ plugins: [react()],
215
+ });`;
216
+ const match = config.match(/plugins\s*:\s*\[/);
217
+ expect(match).not.toBeNull();
218
+ });
219
+ });
220
+ describe('Next.js layout detection', () => {
221
+ it('should check common layout file locations', () => {
222
+ const layoutFiles = [
223
+ 'app/layout.tsx',
224
+ 'app/layout.jsx',
225
+ 'src/app/layout.tsx',
226
+ 'src/app/layout.jsx',
227
+ 'pages/_app.tsx',
228
+ 'pages/_app.jsx',
229
+ 'src/pages/_app.tsx',
230
+ 'src/pages/_app.jsx',
231
+ ];
232
+ expect(layoutFiles).toContain('app/layout.tsx');
233
+ expect(layoutFiles).toContain('pages/_app.tsx');
234
+ expect(layoutFiles.length).toBe(8);
235
+ });
236
+ });
237
+ describe('use client directive handling', () => {
238
+ it('should preserve use client directive at top', () => {
239
+ const fileContent = `'use client';
240
+
241
+ export default function Layout() {}`;
242
+ const importStatement = `import '@eyeglass/inspector';\n`;
243
+ let newContent;
244
+ if (fileContent.startsWith("'use client'") || fileContent.startsWith('"use client"')) {
245
+ const firstLineEnd = fileContent.indexOf('\n') + 1;
246
+ newContent = fileContent.slice(0, firstLineEnd) + importStatement + fileContent.slice(firstLineEnd);
247
+ }
248
+ else {
249
+ newContent = importStatement + fileContent;
250
+ }
251
+ expect(newContent.startsWith("'use client'")).toBe(true);
252
+ expect(newContent).toContain("import '@eyeglass/inspector'");
253
+ // Import should be after 'use client'
254
+ const useClientIndex = newContent.indexOf("'use client'");
255
+ const importIndex = newContent.indexOf("import '@eyeglass/inspector'");
256
+ expect(importIndex).toBeGreaterThan(useClientIndex);
257
+ });
258
+ });
259
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@eyeglass/cli",
3
+ "version": "0.1.0",
4
+ "description": "Visual debugging for AI coding agents - CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "eyeglass": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "vitest run --project node --testNamePattern cli",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "eyeglass",
19
+ "debugging",
20
+ "ai",
21
+ "claude",
22
+ "inspector",
23
+ "mcp",
24
+ "react",
25
+ "vue",
26
+ "svelte",
27
+ "nextjs",
28
+ "vite"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/donutboyband/eyeglass.git",
34
+ "directory": "packages/cli"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.10.0",
38
+ "typescript": "^5.3.0"
39
+ }
40
+ }