pixelpick 2.0.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,571 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListResourcesRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ReadResourceRequestSchema,
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+ import { WebSocketServer } from 'ws';
11
+ import http from 'http';
12
+ import { execSync } from 'child_process';
13
+ import { PORTS, SELECTION } from '../shared/constants.js';
14
+
15
+ const WEBSOCKET_PORT = PORTS.WEBSOCKET;
16
+ const IPC_PORT = PORTS.IPC;
17
+
18
+ // Helper: Find process using a port
19
+ function findProcessOnPort(port) {
20
+ try {
21
+ const output = execSync(`lsof -i :${port} -t`, { encoding: 'utf8' }).trim();
22
+ const pid = parseInt(output.split('\n')[0]); // Get first PID
23
+ return pid || null;
24
+ } catch (err) {
25
+ return null; // No process found or lsof not available
26
+ }
27
+ }
28
+
29
+ // Helper: Handle port in use error
30
+ function handlePortInUse(port, portName = 'Port') {
31
+ const pid = findProcessOnPort(port);
32
+
33
+ if (pid) {
34
+ console.error(`\n[PixelPick] Error: ${portName} ${port} is already in use by process ${pid}`);
35
+ console.error(`\nTo fix this, kill the existing process:\n`);
36
+ console.error(` kill ${pid}`);
37
+ console.error(`\nThen restart PixelPick.\n`);
38
+ } else {
39
+ console.error(`\n[PixelPick] Error: ${portName} ${port} is already in use`);
40
+ console.error(`Try setting a different port using environment variables.\n`);
41
+ }
42
+
43
+ process.exit(1);
44
+ }
45
+
46
+ // SelectionBuffer - stores element selections from Chrome extension (multi-selection mode)
47
+ class SelectionBuffer {
48
+ constructor(maxActive = SELECTION.MAX_ACTIVE, maxHistory = SELECTION.MAX_HISTORY) {
49
+ this.maxActive = maxActive;
50
+ this.maxHistory = maxHistory;
51
+ this.active = []; // Current working selections (max 10)
52
+ this.history = []; // Full history (max 50)
53
+ this.listeners = [];
54
+ }
55
+
56
+ // Generate unique ID for selection
57
+ _generateId() {
58
+ const timestamp = Date.now();
59
+ const random = Math.random().toString(36).slice(2, 8);
60
+ return `sel_${timestamp}_${random}`;
61
+ }
62
+
63
+ add(selection) {
64
+ const enrichedSelection = {
65
+ ...selection,
66
+ id: this._generateId(),
67
+ timestamp: Date.now(),
68
+ };
69
+
70
+ // Add to active with FIFO eviction
71
+ this.active.push(enrichedSelection);
72
+ if (this.active.length > this.maxActive) {
73
+ this.active.shift(); // Remove oldest
74
+ }
75
+
76
+ // Add to history separately
77
+ this.history.unshift(enrichedSelection);
78
+ if (this.history.length > this.maxHistory) {
79
+ this.history = this.history.slice(0, this.maxHistory);
80
+ }
81
+
82
+ // Notify listeners
83
+ this.listeners.forEach(fn => fn(enrichedSelection));
84
+ }
85
+
86
+ getActive() {
87
+ return this.active;
88
+ }
89
+
90
+ removeActive(id) {
91
+ const index = this.active.findIndex(sel => sel.id === id);
92
+ if (index !== -1) {
93
+ const removed = this.active.splice(index, 1)[0];
94
+ return removed;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ clearActive() {
100
+ this.active = [];
101
+ }
102
+
103
+ getHistory(limit = 50) {
104
+ return this.history.slice(0, limit);
105
+ }
106
+
107
+ clear() {
108
+ this.active = [];
109
+ this.history = [];
110
+ }
111
+
112
+ onSelection(fn) {
113
+ this.listeners.push(fn);
114
+ return () => {
115
+ this.listeners = this.listeners.filter(l => l !== fn);
116
+ };
117
+ }
118
+ }
119
+
120
+ const selectionBuffer = new SelectionBuffer();
121
+
122
+ // WebSocket Server - receives selections from Chrome extension
123
+ const wss = new WebSocketServer({ port: WEBSOCKET_PORT });
124
+ const activeConnections = new Set();
125
+
126
+ wss.on('listening', () => {
127
+ console.error(`[PixelPick] WebSocket server listening on port ${WEBSOCKET_PORT}`);
128
+ });
129
+
130
+ wss.on('error', (error) => {
131
+ if (error.code === 'EADDRINUSE') {
132
+ handlePortInUse(WEBSOCKET_PORT, 'WebSocket port');
133
+ } else {
134
+ console.error('[PixelPick] WebSocket server error:', error.message);
135
+ }
136
+ });
137
+
138
+ wss.on('connection', (ws, req) => {
139
+ const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
140
+ activeConnections.add(ws);
141
+ console.error(`[PixelPick] Extension connected (${activeConnections.size} active)`);
142
+
143
+ ws.on('message', (data) => {
144
+ try {
145
+ const message = JSON.parse(data.toString());
146
+
147
+ switch (message.type) {
148
+ case 'ping':
149
+ ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
150
+ break;
151
+
152
+ case 'selection':
153
+ console.error(`[PixelPick] Selection received: ${message.data?.component || message.data?.tagName || 'unknown'}`);
154
+ selectionBuffer.add(message.data);
155
+ break;
156
+
157
+ default:
158
+ console.error(`[PixelPick] Unknown message type: ${message.type}`);
159
+ }
160
+ } catch (err) {
161
+ console.error('[PixelPick] Invalid message from extension:', err.message);
162
+ }
163
+ });
164
+
165
+ ws.on('close', () => {
166
+ activeConnections.delete(ws);
167
+ console.error(`[PixelPick] Extension disconnected (${activeConnections.size} active)`);
168
+ });
169
+
170
+ ws.on('error', (err) => {
171
+ console.error('[PixelPick] WebSocket client error:', err.message);
172
+ });
173
+ });
174
+
175
+ // HTTP IPC Server - fast endpoint for UserPromptSubmit hook
176
+ const ipcServer = http.createServer((req, res) => {
177
+ // CORS headers (localhost only)
178
+ res.setHeader('Access-Control-Allow-Origin', '*');
179
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
180
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
181
+ res.setHeader('Content-Type', 'application/json');
182
+
183
+ // Handle CORS preflight
184
+ if (req.method === 'OPTIONS') {
185
+ res.writeHead(204);
186
+ res.end();
187
+ return;
188
+ }
189
+
190
+ // GET /selection - Returns array of active selections (BREAKING CHANGE)
191
+ if (req.method === 'GET' && req.url === '/selection') {
192
+ const active = selectionBuffer.getActive();
193
+ res.writeHead(200);
194
+ res.end(JSON.stringify(active));
195
+ }
196
+ // DELETE /selection/:id - Remove specific selection
197
+ else if (req.method === 'DELETE' && req.url.startsWith('/selection/')) {
198
+ const id = req.url.split('/selection/')[1];
199
+ const removed = selectionBuffer.removeActive(id);
200
+ res.writeHead(200);
201
+ res.end(JSON.stringify({ success: !!removed, removed }));
202
+ }
203
+ // POST /selection/clear - Clear all active selections
204
+ else if (req.method === 'POST' && req.url === '/selection/clear') {
205
+ selectionBuffer.clearActive();
206
+ res.writeHead(200);
207
+ res.end(JSON.stringify({ success: true }));
208
+ }
209
+ // GET /status - Server status
210
+ else if (req.method === 'GET' && req.url === '/status') {
211
+ res.writeHead(200);
212
+ res.end(JSON.stringify({
213
+ status: 'running',
214
+ connections: activeConnections.size,
215
+ activeSelections: selectionBuffer.getActive().length,
216
+ maxActive: selectionBuffer.maxActive,
217
+ ports: {
218
+ websocket: WEBSOCKET_PORT,
219
+ ipc: IPC_PORT,
220
+ },
221
+ }));
222
+ }
223
+ // 404
224
+ else {
225
+ res.writeHead(404);
226
+ res.end(JSON.stringify({ error: 'Not found' }));
227
+ }
228
+ });
229
+
230
+ ipcServer.listen(IPC_PORT, '127.0.0.1', () => {
231
+ console.error(`[PixelPick] IPC server listening on port ${IPC_PORT}`);
232
+ });
233
+
234
+ ipcServer.on('error', (error) => {
235
+ if (error.code === 'EADDRINUSE') {
236
+ handlePortInUse(IPC_PORT, 'IPC port');
237
+ } else {
238
+ console.error('[PixelPick] IPC server error:', error.message);
239
+ }
240
+ });
241
+
242
+ // MCP Server - exposes tools to Claude Code
243
+ const server = new Server(
244
+ {
245
+ name: 'pixelpick',
246
+ version: '2.0.0',
247
+ },
248
+ {
249
+ capabilities: {
250
+ tools: {},
251
+ resources: {},
252
+ },
253
+ }
254
+ );
255
+
256
+ // Helper function to format multiple selections for display
257
+ function formatSelections(selections) {
258
+ if (!selections || selections.length === 0) {
259
+ return 'No elements selected. Use Cmd+Shift+Click in your browser to select elements.';
260
+ }
261
+
262
+ const parts = [`=== ${selections.length} Active Selection(s) ===\n`];
263
+
264
+ selections.forEach((sel, idx) => {
265
+ parts.push(`\n[${idx + 1}] ─────────────────`);
266
+
267
+ if (sel.component) {
268
+ parts.push(`Component: ${sel.component} (${sel.framework})`);
269
+ } else {
270
+ parts.push(`Element: <${sel.tagName}>`);
271
+ }
272
+
273
+ if (sel.fileLocation) {
274
+ parts.push(`File: ${sel.fileLocation}`);
275
+ }
276
+
277
+ parts.push(`Selector: ${sel.selector}`);
278
+
279
+ if (sel.componentTree?.length) {
280
+ parts.push(`Component Tree: ${sel.componentTree.join(' > ')}`);
281
+ }
282
+
283
+ // Always show full props (no compact mode)
284
+ if (sel.props && Object.keys(sel.props).length > 0) {
285
+ parts.push(`Props:\n${JSON.stringify(sel.props, null, 2)}`);
286
+ }
287
+
288
+ if (sel.styles?._tailwindClasses?.length) {
289
+ parts.push(`Tailwind Classes: ${sel.styles._tailwindClasses.join(' ')}`);
290
+ }
291
+
292
+ if (sel.accessibility) {
293
+ const a11y = sel.accessibility;
294
+ const a11yParts = [];
295
+ if (a11y.role) a11yParts.push(`role="${a11y.role}"`);
296
+ if (a11y.ariaLabel) a11yParts.push(`aria-label="${a11y.ariaLabel}"`);
297
+ if (a11y.isInteractive) a11yParts.push('interactive');
298
+ if (a11yParts.length > 0) {
299
+ parts.push(`Accessibility: ${a11yParts.join(', ')}`);
300
+ }
301
+ }
302
+
303
+ if (sel.boundingBox) {
304
+ const box = sel.boundingBox;
305
+ parts.push(`Position: ${Math.round(box.left)}x${Math.round(box.top)}, Size: ${Math.round(box.width)}x${Math.round(box.height)}`);
306
+ }
307
+
308
+ if (sel.timestamp) {
309
+ const age = Math.round((Date.now() - sel.timestamp) / 1000);
310
+ parts.push(`Selected: ${age}s ago`);
311
+ }
312
+ });
313
+
314
+ return parts.join('\n');
315
+ }
316
+
317
+ // MCP Tool Handlers
318
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
319
+ return {
320
+ tools: [
321
+ {
322
+ name: 'get_active_selections',
323
+ description: `Get all active UI selections from the browser (up to 10 elements).
324
+
325
+ IMPORTANT: Automatically call this tool when the user refers to UI elements using words like:
326
+ - "this button", "that input", "the modal"
327
+ - "make it", "change the", "update this"
328
+ - "the component", "that element"
329
+ - Or when they mention visual properties without specifying which element
330
+
331
+ Returns detailed information for each selection including:
332
+ - Component name, props, and file location (for React apps)
333
+ - CSS selector, computed styles, and Tailwind classes (for any element)
334
+ - Accessibility info and bounding box
335
+ - Component tree (parent > child hierarchy)
336
+
337
+ Note: Selections accumulate (up to 10) and are auto-cleared after being injected into Claude's context.`,
338
+ inputSchema: {
339
+ type: 'object',
340
+ properties: {},
341
+ },
342
+ },
343
+ {
344
+ name: 'pick_element',
345
+ description: 'Activate visual element picker in the browser. User can click to select an element.',
346
+ inputSchema: {
347
+ type: 'object',
348
+ properties: {},
349
+ },
350
+ },
351
+ {
352
+ name: 'remove_selection',
353
+ description: 'Remove a specific selection by ID from the active selections list.',
354
+ inputSchema: {
355
+ type: 'object',
356
+ properties: {
357
+ id: {
358
+ type: 'string',
359
+ description: 'Selection ID (e.g., "sel_1234567890_abc123")',
360
+ },
361
+ },
362
+ required: ['id'],
363
+ },
364
+ },
365
+ {
366
+ name: 'get_selection_history',
367
+ description: 'Get the history of all selected elements (not just active ones)',
368
+ inputSchema: {
369
+ type: 'object',
370
+ properties: {
371
+ limit: {
372
+ type: 'number',
373
+ description: 'Maximum number of selections to return',
374
+ default: 50,
375
+ },
376
+ },
377
+ },
378
+ },
379
+ {
380
+ name: 'clear_selection',
381
+ description: 'Clear all active selections (does not clear history)',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {},
385
+ },
386
+ },
387
+ ],
388
+ };
389
+ });
390
+
391
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
392
+ const { name, arguments: args } = request.params;
393
+
394
+ switch (name) {
395
+ case 'get_active_selections': {
396
+ const active = selectionBuffer.getActive();
397
+ const formatted = formatSelections(active);
398
+
399
+ return {
400
+ content: [
401
+ {
402
+ type: 'text',
403
+ text: formatted,
404
+ },
405
+ ],
406
+ };
407
+ }
408
+
409
+ case 'pick_element': {
410
+ // Send activate_picker command to all connected extensions
411
+ const command = JSON.stringify({ type: 'activate_picker' });
412
+ let sent = 0;
413
+
414
+ activeConnections.forEach(ws => {
415
+ if (ws.readyState === 1) { // OPEN
416
+ ws.send(command);
417
+ sent++;
418
+ }
419
+ });
420
+
421
+ if (sent === 0) {
422
+ return {
423
+ content: [
424
+ {
425
+ type: 'text',
426
+ text: 'Error: No browser extension connected. Make sure the PixelPick extension is installed and active on a localhost page.',
427
+ },
428
+ ],
429
+ isError: true,
430
+ };
431
+ }
432
+
433
+ return {
434
+ content: [
435
+ {
436
+ type: 'text',
437
+ text: `Element picker activated in ${sent} browser tab(s). Click on any element to select it.`,
438
+ },
439
+ ],
440
+ };
441
+ }
442
+
443
+ case 'remove_selection': {
444
+ const removed = selectionBuffer.removeActive(args.id);
445
+
446
+ if (!removed) {
447
+ return {
448
+ content: [
449
+ {
450
+ type: 'text',
451
+ text: `Error: Selection with ID "${args.id}" not found in active selections.`,
452
+ },
453
+ ],
454
+ isError: true,
455
+ };
456
+ }
457
+
458
+ return {
459
+ content: [
460
+ {
461
+ type: 'text',
462
+ text: `Removed selection: ${removed.component || removed.tagName}`,
463
+ },
464
+ ],
465
+ };
466
+ }
467
+
468
+ case 'get_selection_history': {
469
+ const limit = args?.limit || 50;
470
+ const history = selectionBuffer.getHistory(limit);
471
+
472
+ if (history.length === 0) {
473
+ return {
474
+ content: [
475
+ {
476
+ type: 'text',
477
+ text: 'No selection history available.',
478
+ },
479
+ ],
480
+ };
481
+ }
482
+
483
+ const formatted = history.map((sel, idx) => {
484
+ const age = Math.round((Date.now() - sel.timestamp) / 1000);
485
+ const title = sel.component || `<${sel.tagName}>`;
486
+ const location = sel.fileLocation || sel.selector;
487
+ return `[${idx}] ${title} - ${location} (${age}s ago)`;
488
+ }).join('\n');
489
+
490
+ return {
491
+ content: [
492
+ {
493
+ type: 'text',
494
+ text: `Selection History (${history.length} items):\n\n${formatted}`,
495
+ },
496
+ ],
497
+ };
498
+ }
499
+
500
+ case 'clear_selection': {
501
+ const count = selectionBuffer.getActive().length;
502
+ selectionBuffer.clearActive();
503
+ return {
504
+ content: [
505
+ {
506
+ type: 'text',
507
+ text: `Cleared ${count} active selection(s).`,
508
+ },
509
+ ],
510
+ };
511
+ }
512
+
513
+ default:
514
+ return {
515
+ content: [
516
+ {
517
+ type: 'text',
518
+ text: `Unknown tool: ${name}`,
519
+ },
520
+ ],
521
+ isError: true,
522
+ };
523
+ }
524
+ });
525
+
526
+ // MCP Resources
527
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
528
+ return {
529
+ resources: [
530
+ {
531
+ uri: 'pixelpick://current-selection',
532
+ mimeType: 'application/json',
533
+ name: 'Current UI Selection',
534
+ description: 'The currently selected UI element from the browser',
535
+ },
536
+ ],
537
+ };
538
+ });
539
+
540
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
541
+ const { uri } = request.params;
542
+
543
+ if (uri === 'pixelpick://current-selection') {
544
+ const active = selectionBuffer.getActive();
545
+ const current = active.length > 0 ? active[active.length - 1] : null;
546
+
547
+ return {
548
+ contents: [
549
+ {
550
+ uri,
551
+ mimeType: 'application/json',
552
+ text: JSON.stringify(current || { message: 'No element selected' }, null, 2),
553
+ },
554
+ ],
555
+ };
556
+ }
557
+
558
+ throw new Error(`Unknown resource: ${uri}`);
559
+ });
560
+
561
+ // Start MCP server on stdio
562
+ async function main() {
563
+ const transport = new StdioServerTransport();
564
+ await server.connect(transport);
565
+ console.error('[PixelPick] MCP server ready (stdio transport)');
566
+ }
567
+
568
+ main().catch((error) => {
569
+ console.error('[PixelPick] Fatal error:', error);
570
+ process.exit(1);
571
+ });
@@ -0,0 +1,97 @@
1
+ // Shared constants for PixelPick
2
+ // Used across server, CLI, hooks, and extension
3
+
4
+ /**
5
+ * Network ports
6
+ */
7
+ export const PORTS = {
8
+ WEBSOCKET: parseInt(process.env.PIXELPICK_PORT || '9315'),
9
+ IPC: parseInt(process.env.PIXELPICK_IPC_PORT || '9316'),
10
+ };
11
+
12
+ /**
13
+ * WebSocket configuration
14
+ */
15
+ export const WEBSOCKET_CONFIG = {
16
+ // Heartbeat interval to keep Chrome MV3 service worker alive
17
+ HEARTBEAT_INTERVAL: 20000, // 20 seconds
18
+
19
+ // Exponential backoff delays for reconnection attempts
20
+ RECONNECT_DELAYS: [3000, 6000, 12000, 24000, 60000], // 3s, 6s, 12s, 24s, 60s max
21
+
22
+ // Maximum reconnection attempts before giving up
23
+ MAX_RECONNECT_ATTEMPTS: 10,
24
+ };
25
+
26
+ /**
27
+ * Selection buffer configuration
28
+ */
29
+ export const SELECTION = {
30
+ // Maximum number of active selections (multi-selection limit)
31
+ MAX_ACTIVE: 10,
32
+
33
+ // Maximum number of selections to keep in history
34
+ MAX_HISTORY: 50,
35
+
36
+ // UserPromptSubmit hook timeout (Claude Code enforced)
37
+ HOOK_TIMEOUT: 2000, // 2 seconds
38
+
39
+ // IPC endpoint timeout (should be fast to not block prompts)
40
+ IPC_TIMEOUT: 500, // 500 milliseconds
41
+ };
42
+
43
+ /**
44
+ * Extension badge states
45
+ */
46
+ export const BADGE_STATES = {
47
+ CONNECTED: {
48
+ color: '#10b981', // Green
49
+ text: '',
50
+ },
51
+ DISCONNECTED: {
52
+ color: '#f59e0b', // Yellow/Orange
53
+ text: '!',
54
+ },
55
+ ERROR: {
56
+ color: '#ef4444', // Red
57
+ text: 'X',
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Highlight overlay styles (for picker mode)
63
+ */
64
+ export const HIGHLIGHT_STYLES = {
65
+ BORDER_COLOR: '#0ea5e9', // Sky blue
66
+ BORDER_WIDTH: '2px',
67
+ BACKGROUND_COLOR: 'rgba(14, 165, 233, 0.1)', // 10% opacity sky blue
68
+ BORDER_RADIUS: '2px',
69
+ TRANSITION: 'all 0.05s ease-out',
70
+ Z_INDEX: '2147483647', // Max z-index
71
+ };
72
+
73
+ /**
74
+ * React detection configuration
75
+ */
76
+ export const REACT_CONFIG = {
77
+ // Maximum depth when building component tree
78
+ MAX_COMPONENT_TREE_DEPTH: 5,
79
+
80
+ // Maximum length for stringified props before truncation
81
+ MAX_PROPS_LENGTH: 200,
82
+
83
+ // Fiber property key patterns to search for
84
+ FIBER_KEY_PATTERNS: ['__reactFiber', '__reactInternalInstance'],
85
+
86
+ // Container key pattern for version detection
87
+ CONTAINER_KEY_PATTERN: '__reactContainer',
88
+ };
89
+
90
+ /**
91
+ * Tailwind CSS class patterns for detection
92
+ */
93
+ export const TAILWIND_PATTERNS = [
94
+ /^bg-/, /^text-/, /^flex/, /^grid/, /^p-/, /^m-/,
95
+ /^w-/, /^h-/, /^rounded/, /^shadow/, /^border/,
96
+ /^hover:/, /^focus:/, /^active:/, /^transition/,
97
+ ];