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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/chrome-mv3/assets/sidepanel-D7qwGDau.css +1 -0
- package/dist/chrome-mv3/background.js +1 -0
- package/dist/chrome-mv3/chunks/sidepanel-CzCbYvqW.js +13 -0
- package/dist/chrome-mv3/content-scripts/content.js +34 -0
- package/dist/chrome-mv3/icon/128.png +0 -0
- package/dist/chrome-mv3/icon/16.png +0 -0
- package/dist/chrome-mv3/icon/48.png +0 -0
- package/dist/chrome-mv3/manifest.json +1 -0
- package/dist/chrome-mv3/sidepanel.html +13 -0
- package/dist/chrome-mv3/src/constants.js +63 -0
- package/dist/chrome-mv3/src/inspector/dom-inspector.js +165 -0
- package/dist/chrome-mv3/src/inspector/highlight.js +155 -0
- package/dist/chrome-mv3/src/inspector/index.js +82 -0
- package/dist/chrome-mv3/src/inspector/picker.js +101 -0
- package/dist/chrome-mv3/src/inspector/react-detector.js +225 -0
- package/package.json +75 -0
- package/public/icon/128.png +0 -0
- package/public/icon/16.png +0 -0
- package/public/icon/48.png +0 -0
- package/src/cli/index.js +278 -0
- package/src/hooks/check-selection.mjs +123 -0
- package/src/server/index.js +571 -0
- package/src/shared/constants.js +97 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// React component detection via Fiber introspection
|
|
2
|
+
import { REACT_CONFIG } from '../constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects which framework (if any) is used by the element
|
|
6
|
+
* @param {HTMLElement} element
|
|
7
|
+
* @returns {string|null} - Framework name or null
|
|
8
|
+
*/
|
|
9
|
+
export function detectFramework(element) {
|
|
10
|
+
// React: Check for __reactFiber$ or __reactInternalInstance$ property
|
|
11
|
+
const fiberKey = Object.keys(element).find(k =>
|
|
12
|
+
REACT_CONFIG.FIBER_KEY_PATTERNS.some(pattern => k.startsWith(pattern))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (fiberKey) return 'react';
|
|
16
|
+
|
|
17
|
+
// Future: Vue, Svelte, etc.
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gets the component name from a React fiber
|
|
23
|
+
* @param {Object} fiber - React fiber object
|
|
24
|
+
* @returns {string|null}
|
|
25
|
+
*/
|
|
26
|
+
export function getComponentName(fiber) {
|
|
27
|
+
if (!fiber || !fiber.type) return null;
|
|
28
|
+
|
|
29
|
+
// Function component or class component
|
|
30
|
+
if (typeof fiber.type === 'function') {
|
|
31
|
+
return fiber.type.displayName || fiber.type.name || 'Anonymous';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detects React version
|
|
39
|
+
* @returns {string} - React version or 'unknown'
|
|
40
|
+
*/
|
|
41
|
+
export function getReactVersion() {
|
|
42
|
+
try {
|
|
43
|
+
// Check for React in window
|
|
44
|
+
if (window.React) {
|
|
45
|
+
const internals = window.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
|
46
|
+
return internals?.ReactVersion || 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Try to detect from fiber
|
|
50
|
+
const rootElements = document.querySelectorAll('[id*="root"]');
|
|
51
|
+
for (const root of rootElements) {
|
|
52
|
+
const fiberKey = Object.keys(root).find(k =>
|
|
53
|
+
k.startsWith(REACT_CONFIG.CONTAINER_KEY_PATTERN)
|
|
54
|
+
);
|
|
55
|
+
if (fiberKey) {
|
|
56
|
+
// Heuristic: React 18+ uses __reactContainer
|
|
57
|
+
return '18+';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 'unknown';
|
|
62
|
+
} catch {
|
|
63
|
+
return 'unknown';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sanitizes React props for safe serialization
|
|
69
|
+
* @param {Object} props - React component props
|
|
70
|
+
* @returns {Object|null}
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeProps(props) {
|
|
73
|
+
if (!props || typeof props !== 'object') return null;
|
|
74
|
+
|
|
75
|
+
const sanitized = {};
|
|
76
|
+
|
|
77
|
+
for (const [key, value] of Object.entries(props)) {
|
|
78
|
+
// Skip children (too large)
|
|
79
|
+
if (key === 'children') continue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Handle functions
|
|
83
|
+
if (typeof value === 'function') {
|
|
84
|
+
sanitized[key] = `[Function: ${value.name || 'anonymous'}]`;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle symbols
|
|
89
|
+
if (typeof value === 'symbol') {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle objects (with truncation)
|
|
94
|
+
if (typeof value === 'object' && value !== null) {
|
|
95
|
+
const str = JSON.stringify(value);
|
|
96
|
+
if (str.length > REACT_CONFIG.MAX_PROPS_LENGTH) {
|
|
97
|
+
sanitized[key] = str.slice(0, REACT_CONFIG.MAX_PROPS_LENGTH) + '...';
|
|
98
|
+
} else {
|
|
99
|
+
sanitized[key] = value;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Primitives
|
|
105
|
+
sanitized[key] = value;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Circular reference or other error
|
|
108
|
+
sanitized[key] = '[Object]';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Builds component tree by walking up the fiber tree
|
|
117
|
+
* @param {Object} fiber - Starting React fiber
|
|
118
|
+
* @param {number} maxDepth - Maximum depth to traverse
|
|
119
|
+
* @returns {Array<string>|null}
|
|
120
|
+
*/
|
|
121
|
+
export function buildComponentTree(fiber, maxDepth = REACT_CONFIG.MAX_COMPONENT_TREE_DEPTH) {
|
|
122
|
+
const tree = [];
|
|
123
|
+
let current = fiber;
|
|
124
|
+
let depth = 0;
|
|
125
|
+
|
|
126
|
+
while (current && depth < maxDepth) {
|
|
127
|
+
if (typeof current.type !== 'string') {
|
|
128
|
+
const name = getComponentName(current);
|
|
129
|
+
if (name) {
|
|
130
|
+
tree.push(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
current = current.return;
|
|
135
|
+
depth++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return tree.length > 0 ? tree.reverse() : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extracts React component data from a DOM element
|
|
143
|
+
* @param {HTMLElement} element
|
|
144
|
+
* @returns {Object|null} - Component data or null
|
|
145
|
+
*/
|
|
146
|
+
export function extractReactData(element) {
|
|
147
|
+
try {
|
|
148
|
+
// Find fiber key
|
|
149
|
+
const fiberKey = Object.keys(element).find(k =>
|
|
150
|
+
REACT_CONFIG.FIBER_KEY_PATTERNS.some(pattern => k.startsWith(pattern))
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (!fiberKey) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const fiber = element[fiberKey];
|
|
158
|
+
if (!fiber) return null;
|
|
159
|
+
|
|
160
|
+
// Walk up to find component fiber (skip host fibers like 'div')
|
|
161
|
+
let componentFiber = fiber;
|
|
162
|
+
while (componentFiber && typeof componentFiber.type === 'string') {
|
|
163
|
+
componentFiber = componentFiber.return;
|
|
164
|
+
if (!componentFiber) break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!componentFiber) {
|
|
168
|
+
return {
|
|
169
|
+
name: null,
|
|
170
|
+
props: null,
|
|
171
|
+
fileLocation: null,
|
|
172
|
+
tree: null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Extract component name
|
|
177
|
+
const name = getComponentName(componentFiber);
|
|
178
|
+
|
|
179
|
+
// Extract props
|
|
180
|
+
const props = sanitizeProps(componentFiber.memoizedProps);
|
|
181
|
+
|
|
182
|
+
// Extract file location
|
|
183
|
+
let fileLocation = null;
|
|
184
|
+
|
|
185
|
+
// Try _debugSource (React 18)
|
|
186
|
+
if (componentFiber._debugSource) {
|
|
187
|
+
const src = componentFiber._debugSource;
|
|
188
|
+
fileLocation = `${src.fileName}:${src.lineNumber}:${src.columnNumber}`;
|
|
189
|
+
} else {
|
|
190
|
+
// Fallback: Parse stack trace (React 19)
|
|
191
|
+
try {
|
|
192
|
+
const stack = new Error().stack;
|
|
193
|
+
const lines = stack.split('\n');
|
|
194
|
+
// Find first non-node_modules line
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
if (line.includes('.jsx') || line.includes('.tsx') || line.includes('.js')) {
|
|
197
|
+
if (!line.includes('node_modules') && !line.includes('inspector.js')) {
|
|
198
|
+
// Extract file:line from stack trace
|
|
199
|
+
const match = line.match(/([^/\s]+\.(jsx?|tsx?)):(\d+):(\d+)/);
|
|
200
|
+
if (match) {
|
|
201
|
+
fileLocation = `${match[1]}:${match[3]}`;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Stack parsing failed
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build component tree
|
|
213
|
+
const tree = buildComponentTree(componentFiber);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
name,
|
|
217
|
+
props,
|
|
218
|
+
fileLocation,
|
|
219
|
+
tree,
|
|
220
|
+
};
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error('[PixelPick] React extraction error:', err);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pixelpick",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Visual UI inspector for Claude Code via MCP",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pixelpick": "src/cli/index.js",
|
|
8
|
+
"pixelpick-server": "src/server/index.js",
|
|
9
|
+
"pixelpick-check-selection": "src/hooks/check-selection.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"dist/chrome-mv3/",
|
|
14
|
+
"public/icon/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "wxt",
|
|
20
|
+
"build": "wxt build",
|
|
21
|
+
"build:firefox": "wxt build --browser firefox",
|
|
22
|
+
"zip": "wxt zip",
|
|
23
|
+
"start": "node src/server/index.js",
|
|
24
|
+
"test": "node --test tests/**/*.test.js",
|
|
25
|
+
"serve-test": "node tests/helpers/serve-test.js",
|
|
26
|
+
"test:react": "npm run test:ensure-deps && cd tests/react-app && npm run dev",
|
|
27
|
+
"test:ensure-deps": "[ -d tests/react-app/node_modules ] || (echo '\nInstalling React test app dependencies...\n' && cd tests/react-app && npm install)",
|
|
28
|
+
"test:setup": "cd tests/react-app && npm install",
|
|
29
|
+
"generate-icons": "node scripts/generate-icons.mjs",
|
|
30
|
+
"prepublishOnly": "wxt build",
|
|
31
|
+
"postinstall": "chmod +x src/cli/index.js src/server/index.js src/hooks/check-selection.mjs 2>/dev/null || true"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
|
+
"ws": "^8.16.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp",
|
|
42
|
+
"claude",
|
|
43
|
+
"claude-code",
|
|
44
|
+
"model-context-protocol",
|
|
45
|
+
"ui-inspector",
|
|
46
|
+
"developer-tools",
|
|
47
|
+
"chrome-extension",
|
|
48
|
+
"react"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"author": "bilune",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/bilune/pixelpick.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/bilune/pixelpick/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/bilune/pixelpick#readme",
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
62
|
+
"@types/chrome": "^0.1.36",
|
|
63
|
+
"@types/react": "^19.2.14",
|
|
64
|
+
"@types/react-dom": "^19.2.3",
|
|
65
|
+
"@wxt-dev/module-react": "^1.1.5",
|
|
66
|
+
"autoprefixer": "^10.4.24",
|
|
67
|
+
"lucide-react": "^0.564.0",
|
|
68
|
+
"postcss": "^8.5.6",
|
|
69
|
+
"react": "^19.2.4",
|
|
70
|
+
"react-dom": "^19.2.4",
|
|
71
|
+
"tailwindcss": "^4.0.0",
|
|
72
|
+
"typescript": "^5.9.3",
|
|
73
|
+
"wxt": "^0.20.17"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const command = process.argv[2];
|
|
9
|
+
|
|
10
|
+
// Flip to true once Chrome Web Store listing is approved
|
|
11
|
+
const WEB_STORE_AVAILABLE = false;
|
|
12
|
+
const WEB_STORE_URL = ''; // e.g. https://chrome.google.com/webstore/detail/pixelpick/...
|
|
13
|
+
|
|
14
|
+
switch (command) {
|
|
15
|
+
case 'init':
|
|
16
|
+
init();
|
|
17
|
+
break;
|
|
18
|
+
case 'start':
|
|
19
|
+
case undefined:
|
|
20
|
+
startServer();
|
|
21
|
+
break;
|
|
22
|
+
case 'status':
|
|
23
|
+
checkStatus();
|
|
24
|
+
break;
|
|
25
|
+
case 'help':
|
|
26
|
+
case '--help':
|
|
27
|
+
case '-h':
|
|
28
|
+
showHelp();
|
|
29
|
+
break;
|
|
30
|
+
case '--version':
|
|
31
|
+
case '-v':
|
|
32
|
+
showVersion();
|
|
33
|
+
break;
|
|
34
|
+
default:
|
|
35
|
+
console.error(`Unknown command: ${command}\n`);
|
|
36
|
+
showHelp();
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function init() {
|
|
41
|
+
const isProjectScope = process.argv.includes('--project');
|
|
42
|
+
const scope = isProjectScope ? 'project' : 'user';
|
|
43
|
+
|
|
44
|
+
console.log('PixelPick -- Setting up Claude Code integration...\n');
|
|
45
|
+
|
|
46
|
+
// Step 0: Check prerequisites
|
|
47
|
+
console.log('[1/4] Checking prerequisites...');
|
|
48
|
+
try {
|
|
49
|
+
execSync('claude --version', { stdio: 'pipe' });
|
|
50
|
+
console.log(' Claude CLI found.\n');
|
|
51
|
+
} catch {
|
|
52
|
+
console.error(' Claude CLI not found.\n');
|
|
53
|
+
console.error(' Install it first:');
|
|
54
|
+
console.error(' npm install -g @anthropic-ai/claude-code\n');
|
|
55
|
+
console.error(' Then run this command again: npx pixelpick init');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 1: Register MCP server
|
|
60
|
+
console.log('[2/4] Registering MCP server...');
|
|
61
|
+
try {
|
|
62
|
+
const serverPath = join(__dirname, '../server/index.js');
|
|
63
|
+
try {
|
|
64
|
+
execSync(
|
|
65
|
+
`claude mcp add --scope ${scope} pixelpick -- node "${serverPath}"`,
|
|
66
|
+
{ stdio: 'pipe' }
|
|
67
|
+
);
|
|
68
|
+
console.log(' MCP server registered.\n');
|
|
69
|
+
} catch (addErr) {
|
|
70
|
+
// Check if it already exists
|
|
71
|
+
const listOutput = execSync('claude mcp list', { encoding: 'utf8', stdio: 'pipe' });
|
|
72
|
+
if (listOutput.includes('pixelpick')) {
|
|
73
|
+
console.log(' MCP server already registered (skipped).\n');
|
|
74
|
+
} else {
|
|
75
|
+
throw addErr;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(' Failed to register MCP server.');
|
|
80
|
+
console.error(' Error:', err.message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Step 2: Add UserPromptSubmit hook
|
|
85
|
+
console.log('[3/4] Installing selection injection hook...');
|
|
86
|
+
const settingsPath = isProjectScope
|
|
87
|
+
? join(process.cwd(), '.claude', 'settings.json')
|
|
88
|
+
: join(process.env.HOME, '.claude', 'settings.json');
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const dir = dirname(settingsPath);
|
|
92
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
let settings = {};
|
|
95
|
+
if (existsSync(settingsPath)) {
|
|
96
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!settings.hooks) settings.hooks = {};
|
|
100
|
+
if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
|
|
101
|
+
|
|
102
|
+
// Check if hook already exists
|
|
103
|
+
const hookExists = settings.hooks.UserPromptSubmit.some(
|
|
104
|
+
h => h.hooks?.some(hook => hook.command?.includes('pixelpick'))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (!hookExists) {
|
|
108
|
+
const checkSelectionPath = join(__dirname, '../hooks/check-selection.mjs');
|
|
109
|
+
settings.hooks.UserPromptSubmit.push({
|
|
110
|
+
hooks: [{
|
|
111
|
+
type: 'command',
|
|
112
|
+
command: `node "${checkSelectionPath}"`,
|
|
113
|
+
timeout: 2000
|
|
114
|
+
}]
|
|
115
|
+
});
|
|
116
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
117
|
+
console.log(' Hook installed.\n');
|
|
118
|
+
} else {
|
|
119
|
+
console.log(' Hook already installed (skipped).\n');
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(' Failed to install hook:', err.message);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Step 3: Chrome Extension guidance
|
|
127
|
+
console.log('[4/4] Chrome Extension...');
|
|
128
|
+
|
|
129
|
+
if (WEB_STORE_AVAILABLE) {
|
|
130
|
+
console.log(` Install from Chrome Web Store:`);
|
|
131
|
+
console.log(` ${WEB_STORE_URL}\n`);
|
|
132
|
+
} else {
|
|
133
|
+
// Find the built extension in the package
|
|
134
|
+
const extensionPath = join(__dirname, '../../dist/chrome-mv3');
|
|
135
|
+
|
|
136
|
+
if (existsSync(extensionPath)) {
|
|
137
|
+
console.log(' Load the extension manually:\n');
|
|
138
|
+
console.log(` 1. Open chrome://extensions`);
|
|
139
|
+
console.log(` 2. Enable "Developer mode" (top right)`);
|
|
140
|
+
console.log(` 3. Click "Load unpacked"`);
|
|
141
|
+
console.log(` 4. Select: ${extensionPath}\n`);
|
|
142
|
+
|
|
143
|
+
// Try to copy path to clipboard (macOS)
|
|
144
|
+
try {
|
|
145
|
+
execSync(`echo "${extensionPath}" | pbcopy`, { stdio: 'pipe' });
|
|
146
|
+
console.log(' (Extension path copied to clipboard)\n');
|
|
147
|
+
} catch {
|
|
148
|
+
// Silent fail -- pbcopy not available on Linux/Windows
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Try to open chrome://extensions
|
|
152
|
+
try {
|
|
153
|
+
execSync('open "chrome://extensions"', { stdio: 'pipe' });
|
|
154
|
+
} catch {
|
|
155
|
+
// Silent fail -- not macOS or Chrome not default browser
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
console.log(' Extension build not found. Build it first:\n');
|
|
159
|
+
console.log(' cd node_modules/pixelpick && npm run build\n');
|
|
160
|
+
console.log(' Then load dist/chrome-mv3/ as unpacked extension.');
|
|
161
|
+
console.log(' See: https://github.com/bilune/pixelpick#setup\n');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Done
|
|
166
|
+
console.log('--- Setup complete! ---\n');
|
|
167
|
+
console.log('How to use:');
|
|
168
|
+
console.log(' 1. Open your app in Chrome (localhost)');
|
|
169
|
+
console.log(' 2. Cmd+Shift+Click on any element');
|
|
170
|
+
console.log(' 3. Switch to Claude Code and describe your change');
|
|
171
|
+
console.log(' 4. Claude automatically sees what you selected\n');
|
|
172
|
+
console.log('Verify: npx pixelpick status');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function startServer() {
|
|
176
|
+
import('../server/index.js');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function checkStatus() {
|
|
180
|
+
console.log('PixelPick Status');
|
|
181
|
+
console.log('-'.repeat(42) + '\n');
|
|
182
|
+
|
|
183
|
+
// Check MCP server
|
|
184
|
+
let serverRunning = false;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch('http://127.0.0.1:9316/status');
|
|
188
|
+
const data = await response.json();
|
|
189
|
+
serverRunning = true;
|
|
190
|
+
console.log(`MCP Server: Running (localhost:${data.ports.websocket})`);
|
|
191
|
+
console.log(`Active Connections: ${data.connections}`);
|
|
192
|
+
|
|
193
|
+
// Get active selections
|
|
194
|
+
const selectionResponse = await fetch('http://127.0.0.1:9316/selection');
|
|
195
|
+
const selections = await selectionResponse.json();
|
|
196
|
+
if (Array.isArray(selections) && selections.length > 0) {
|
|
197
|
+
console.log(`Active Selections: ${selections.length}`);
|
|
198
|
+
selections.forEach((sel, idx) => {
|
|
199
|
+
const label = sel.component
|
|
200
|
+
? `${sel.component} (${sel.framework})`
|
|
201
|
+
: `<${sel.tagName}>`;
|
|
202
|
+
const age = Math.round((Date.now() - sel.timestamp) / 1000);
|
|
203
|
+
console.log(` [${idx + 1}] ${label} - ${age}s ago`);
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
console.log('Active Selections: None');
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
console.log('MCP Server: Not running');
|
|
210
|
+
console.log(' Run: npx pixelpick start');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check Claude Code registration
|
|
214
|
+
try {
|
|
215
|
+
const result = execSync('claude mcp list', { encoding: 'utf8', stdio: 'pipe' });
|
|
216
|
+
if (result.includes('pixelpick')) {
|
|
217
|
+
console.log('Claude Code: Registered');
|
|
218
|
+
} else {
|
|
219
|
+
console.log('Claude Code: Not registered');
|
|
220
|
+
console.log(' Run: npx pixelpick init');
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
console.log('Claude Code: Could not check');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check hook
|
|
227
|
+
try {
|
|
228
|
+
const settingsPath = join(process.env.HOME, '.claude', 'settings.json');
|
|
229
|
+
if (existsSync(settingsPath)) {
|
|
230
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
231
|
+
const hasHook = settings.hooks?.UserPromptSubmit?.some(
|
|
232
|
+
h => h.hooks?.some(hook => hook.command?.includes('pixelpick'))
|
|
233
|
+
);
|
|
234
|
+
if (hasHook) {
|
|
235
|
+
console.log('UserPrompt Hook: Installed');
|
|
236
|
+
} else {
|
|
237
|
+
console.log('UserPrompt Hook: Not installed');
|
|
238
|
+
console.log(' Run: npx pixelpick init');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
console.log('UserPrompt Hook: Could not check');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function showHelp() {
|
|
249
|
+
console.log(`
|
|
250
|
+
PixelPick -- Visual UI inspector for Claude Code
|
|
251
|
+
|
|
252
|
+
Commands:
|
|
253
|
+
pixelpick init Set up MCP server, hooks, and extension
|
|
254
|
+
pixelpick start Start the MCP server
|
|
255
|
+
pixelpick status Check connection status
|
|
256
|
+
pixelpick help Show this help message
|
|
257
|
+
|
|
258
|
+
Options:
|
|
259
|
+
--project Use project scope instead of user scope (for init)
|
|
260
|
+
--version, -v Show version number
|
|
261
|
+
|
|
262
|
+
Environment Variables:
|
|
263
|
+
PIXELPICK_PORT WebSocket port (default: 9315)
|
|
264
|
+
PIXELPICK_IPC_PORT HTTP IPC port (default: 9316)
|
|
265
|
+
|
|
266
|
+
More info: https://github.com/bilune/pixelpick
|
|
267
|
+
`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function showVersion() {
|
|
271
|
+
try {
|
|
272
|
+
const pkgPath = join(__dirname, '../../package.json');
|
|
273
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
274
|
+
console.log(`pixelpick v${pkg.version}`);
|
|
275
|
+
} catch {
|
|
276
|
+
console.log('pixelpick (version unknown)');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { PORTS, SELECTION } from '../shared/constants.js';
|
|
4
|
+
|
|
5
|
+
const IPC_PORT = PORTS.IPC;
|
|
6
|
+
|
|
7
|
+
async function checkSelection() {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const req = http.get(
|
|
10
|
+
`http://127.0.0.1:${IPC_PORT}/selection`,
|
|
11
|
+
{ timeout: SELECTION.IPC_TIMEOUT }, // Fast timeout - must not delay prompts
|
|
12
|
+
(res) => {
|
|
13
|
+
let data = '';
|
|
14
|
+
res.on('data', chunk => data += chunk);
|
|
15
|
+
res.on('end', () => {
|
|
16
|
+
try {
|
|
17
|
+
const selections = JSON.parse(data);
|
|
18
|
+
resolve(selections);
|
|
19
|
+
} catch {
|
|
20
|
+
resolve(null);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
req.on('error', () => resolve(null)); // Server not running = no selections
|
|
26
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function clearSelections() {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const postData = '';
|
|
33
|
+
const options = {
|
|
34
|
+
hostname: '127.0.0.1',
|
|
35
|
+
port: IPC_PORT,
|
|
36
|
+
path: '/selection/clear',
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
41
|
+
},
|
|
42
|
+
timeout: SELECTION.IPC_TIMEOUT,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const req = http.request(options, (res) => {
|
|
46
|
+
// Silent success
|
|
47
|
+
resolve(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
req.on('error', () => resolve(false)); // Silent failure
|
|
51
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
52
|
+
req.write(postData);
|
|
53
|
+
req.end();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
const selections = await checkSelection();
|
|
59
|
+
|
|
60
|
+
// If no selections or invalid data, exit silently
|
|
61
|
+
if (!selections || !Array.isArray(selections) || selections.length === 0) {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build formatted context for multiple selections
|
|
67
|
+
const parts = ['[PixelPick — Active Browser Selections]'];
|
|
68
|
+
|
|
69
|
+
selections.forEach((selection, idx) => {
|
|
70
|
+
parts.push(`\n[${idx + 1}] ─────────────────`);
|
|
71
|
+
|
|
72
|
+
if (selection.component) {
|
|
73
|
+
parts.push(`Component: ${selection.component} (${selection.framework})`);
|
|
74
|
+
} else {
|
|
75
|
+
parts.push(`Element: <${selection.tagName}>`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (selection.fileLocation) {
|
|
79
|
+
parts.push(`File: ${selection.fileLocation}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
parts.push(`Selector: ${selection.selector}`);
|
|
83
|
+
|
|
84
|
+
if (selection.componentTree?.length) {
|
|
85
|
+
parts.push(`Tree: ${selection.componentTree.join(' > ')}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Always show full props (no compact mode)
|
|
89
|
+
if (selection.props && Object.keys(selection.props).length > 0) {
|
|
90
|
+
parts.push(`Props: ${JSON.stringify(selection.props, null, 0)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (selection.styles?._tailwindClasses?.length) {
|
|
94
|
+
parts.push(`Tailwind: ${selection.styles._tailwindClasses.join(' ')}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (selection.accessibility?.isInteractive) {
|
|
98
|
+
const a11y = [];
|
|
99
|
+
if (selection.accessibility.role) a11y.push(`role="${selection.accessibility.role}"`);
|
|
100
|
+
if (selection.accessibility.ariaLabel) a11y.push(`aria-label="${selection.accessibility.ariaLabel}"`);
|
|
101
|
+
if (a11y.length > 0) {
|
|
102
|
+
parts.push(`A11y: ${a11y.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const context = parts.join('\n');
|
|
108
|
+
|
|
109
|
+
// Output as JSON with additionalContext
|
|
110
|
+
const output = {
|
|
111
|
+
hookSpecificOutput: {
|
|
112
|
+
hookEventName: 'UserPromptSubmit',
|
|
113
|
+
additionalContext: context
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
process.stdout.write(JSON.stringify(output));
|
|
118
|
+
|
|
119
|
+
// Auto-clear selections after injection (fire-and-forget)
|
|
120
|
+
clearSelections().then(() => process.exit(0));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main();
|