cluso-inspector 1.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/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # cluso-inspector
2
+
3
+ Visual element selector for extracting HTML and screenshots from any website.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Use directly with npx (no installation required)
9
+ npx cluso-inspector https://github.com
10
+
11
+ # Or install globally
12
+ npm install -g cluso-inspector
13
+ cluso-inspector https://github.com
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```bash
19
+ cluso-inspector <url> [language]
20
+ ```
21
+
22
+ ### Arguments
23
+
24
+ - **url** (required) - The URL to open and extract from
25
+ - **language** (optional) - Target language: `typescript` or `javascript` (default: `typescript`)
26
+
27
+ ### Examples
28
+
29
+ ```bash
30
+ # Extract from GitHub
31
+ npx cluso-inspector https://github.com
32
+
33
+ # Specify JavaScript output
34
+ npx cluso-inspector https://github.com javascript
35
+
36
+ # Extract from any site
37
+ npx cluso-inspector https://example.com typescript
38
+ ```
39
+
40
+ ## How It Works
41
+
42
+ 1. **Launch**: Opens the URL in an Electron browser window
43
+ 2. **Select**: Hover over elements (blue outline), click to select (purple outline)
44
+ 3. **Extract**: Click "Select" button to capture HTML + screenshot
45
+ 4. **Output**: Returns JSON to stdout with all extracted data
46
+
47
+ ## Output Format
48
+
49
+ The tool outputs JSON to stdout:
50
+
51
+ ```json
52
+ {
53
+ "success": true,
54
+ "url": "https://github.com",
55
+ "timestamp": "2026-01-11T10:00:00.000Z",
56
+ "language": "typescript",
57
+ "extractions": [
58
+ {
59
+ "selector": "div.hero > div.container",
60
+ "isReact": false,
61
+ "component": { ... },
62
+ "html": "<div class=\"hero\">...</div>",
63
+ "screenshot": "data:image/png;base64,...",
64
+ "dimensions": {
65
+ "width": 1200,
66
+ "height": 600,
67
+ "top": 100,
68
+ "left": 120
69
+ },
70
+ "meta": {
71
+ "tagName": "div",
72
+ "id": "hero-section",
73
+ "classes": ["hero", "container"]
74
+ }
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ ## Use Cases
81
+
82
+ - **Component extraction**: Extract React/HTML components from live sites
83
+ - **Design replication**: Capture visual elements with pixel-perfect screenshots
84
+ - **Automated testing**: Extract DOM structure for test validation
85
+ - **Documentation**: Capture UI elements with code + screenshot
86
+
87
+ ## Piping to Files
88
+
89
+ ```bash
90
+ # Save JSON to file
91
+ npx cluso-inspector https://github.com > extraction.json
92
+
93
+ # Extract screenshot separately
94
+ npx cluso-inspector https://github.com | jq -r '.extractions[0].screenshot' > screenshot.png
95
+ ```
96
+
97
+ ## Integration
98
+
99
+ Use with other tools via stdout:
100
+
101
+ ```javascript
102
+ const { execSync } = require('child_process');
103
+ const data = JSON.parse(
104
+ execSync('npx cluso-inspector https://github.com').toString()
105
+ );
106
+ console.log(data.extractions[0].selector);
107
+ ```
108
+
109
+ ## Keyboard Shortcuts
110
+
111
+ - **Click**: Select element
112
+ - **ESC**: Cancel and close
113
+ - **Clear**: Clear current selection
114
+
115
+ ## Requirements
116
+
117
+ - Node.js 14+
118
+ - Electron 28+ (auto-installed)
119
+
120
+ ## License
121
+
122
+ MIT
Binary file
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cluso-inspector CLI
5
+ *
6
+ * Usage:
7
+ * cluso-inspector <url> [language]
8
+ *
9
+ * Examples:
10
+ * cluso-inspector https://github.com
11
+ * cluso-inspector https://github.com typescript
12
+ * npx cluso-inspector https://example.com javascript
13
+ */
14
+
15
+ const { spawn } = require('child_process');
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+
19
+ // Parse CLI arguments
20
+ const args = process.argv.slice(2);
21
+
22
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
23
+ console.log(`
24
+ cluso-inspector - Visual element selector for web components
25
+
26
+ Usage:
27
+ cluso-inspector <url> [language]
28
+
29
+ Arguments:
30
+ url - The URL to open and extract from (required)
31
+ language - Target language: typescript or javascript (default: typescript)
32
+
33
+ Examples:
34
+ cluso-inspector https://github.com
35
+ cluso-inspector https://github.com typescript
36
+ npx cluso-inspector https://example.com javascript
37
+
38
+ Output:
39
+ Outputs JSON to stdout with: { screenshot, html, selector, dimensions, ... }
40
+ `);
41
+ process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
42
+ }
43
+
44
+ const url = args[0];
45
+ const language = args[1] || 'typescript';
46
+
47
+ // Validate URL
48
+ try {
49
+ new URL(url);
50
+ } catch (error) {
51
+ console.error('Error: Invalid URL:', url);
52
+ process.exit(1);
53
+ }
54
+
55
+ // Validate language
56
+ if (!['typescript', 'javascript'].includes(language)) {
57
+ console.error('Error: Language must be "typescript" or "javascript"');
58
+ process.exit(1);
59
+ }
60
+
61
+ // Create temp file for output (Electron will write here, we'll read and output to stdout)
62
+ const tmpFile = `/tmp/cluso-inspector-${process.pid}.json`;
63
+
64
+ // Path to Electron app
65
+ const electronPath = require('electron');
66
+ const mainPath = path.join(__dirname, '..', 'main.js');
67
+
68
+ // Launch Electron
69
+ const electron = spawn(electronPath, [mainPath, url, tmpFile, language], {
70
+ stdio: ['inherit', 'pipe', 'pipe']
71
+ });
72
+
73
+ // Capture electron stdout/stderr (for debugging)
74
+ electron.stdout.on('data', (data) => {
75
+ // Log to stderr so it doesn't interfere with JSON output
76
+ process.stderr.write(`[cluso-inspector] ${data}`);
77
+ });
78
+
79
+ electron.stderr.on('data', (data) => {
80
+ process.stderr.write(`[cluso-inspector] ${data}`);
81
+ });
82
+
83
+ // When Electron exits
84
+ electron.on('close', (code) => {
85
+ if (code === 0) {
86
+ // Read the temp file and output to stdout
87
+ if (fs.existsSync(tmpFile)) {
88
+ const data = fs.readFileSync(tmpFile, 'utf8');
89
+ console.log(data); // Output JSON to stdout
90
+
91
+ // Clean up temp file
92
+ try {
93
+ fs.unlinkSync(tmpFile);
94
+ } catch (e) {
95
+ // Ignore cleanup errors
96
+ }
97
+
98
+ process.exit(0);
99
+ } else {
100
+ console.error('Error: Extraction data not found');
101
+ process.exit(1);
102
+ }
103
+ } else if (code === 1) {
104
+ // User cancelled
105
+ console.error('Extraction cancelled');
106
+ process.exit(1);
107
+ } else {
108
+ console.error('Error: Electron process failed with code', code);
109
+ process.exit(code || 1);
110
+ }
111
+ });
112
+
113
+ // Handle signals
114
+ process.on('SIGINT', () => {
115
+ electron.kill('SIGINT');
116
+ process.exit(1);
117
+ });
118
+
119
+ process.on('SIGTERM', () => {
120
+ electron.kill('SIGTERM');
121
+ process.exit(1);
122
+ });
@@ -0,0 +1,644 @@
1
+ /**
2
+ * CloneReact Element Inspector
3
+ * Adapted from ai-cluso shared-inspector package
4
+ * Provides visual overlay for element selection
5
+ */
6
+
7
+ // Import styles (will be read from inspector-styles.ts)
8
+ const INSPECTOR_STYLES = `
9
+ #cluso-hover-overlay {
10
+ position: absolute;
11
+ pointer-events: none;
12
+ border: 2px dashed #3b82f6;
13
+ border-radius: 8px;
14
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
15
+ z-index: 999998;
16
+ opacity: 0;
17
+ transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
18
+ }
19
+ #cluso-hover-overlay.visible {
20
+ opacity: 1;
21
+ }
22
+
23
+ #cluso-screenshot-overlay {
24
+ position: absolute;
25
+ pointer-events: none;
26
+ border: 2px solid #3b82f6;
27
+ border-radius: 8px;
28
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
29
+ z-index: 999998;
30
+ opacity: 0;
31
+ transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
32
+ }
33
+ #cluso-screenshot-overlay.visible {
34
+ opacity: 1;
35
+ }
36
+
37
+ #cluso-selection-overlay {
38
+ position: absolute;
39
+ pointer-events: none;
40
+ border: 3px solid #8b5cf6;
41
+ border-radius: 8px;
42
+ box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.3), 0 4px 12px rgba(139, 92, 246, 0.4);
43
+ z-index: 999999;
44
+ opacity: 0;
45
+ transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
46
+ }
47
+ #cluso-selection-overlay.visible {
48
+ opacity: 1;
49
+ }
50
+
51
+ .cluso-overlay-label {
52
+ position: absolute;
53
+ top: -24px;
54
+ left: 0;
55
+ background: rgba(0, 0, 0, 0.85);
56
+ color: white;
57
+ padding: 4px 8px;
58
+ border-radius: 4px;
59
+ font-size: 11px;
60
+ font-weight: 600;
61
+ white-space: nowrap;
62
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
63
+ }
64
+
65
+ #cluso-screenshot-overlay .cluso-overlay-label {
66
+ background: rgba(59, 130, 246, 0.9);
67
+ }
68
+
69
+ #cluso-selection-overlay .cluso-overlay-label {
70
+ background: rgba(139, 92, 246, 0.9);
71
+ }
72
+
73
+ #clonereact-toolbar {
74
+ position: fixed;
75
+ top: 20px;
76
+ left: 50%;
77
+ transform: translateX(-50%);
78
+ background: rgba(0, 0, 0, 0.95);
79
+ border: 1px solid rgba(139, 92, 246, 0.3);
80
+ border-radius: 12px;
81
+ padding: 12px 16px;
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 12px;
85
+ backdrop-filter: blur(10px);
86
+ z-index: 1000000;
87
+ box-shadow: 0 10px 40px rgba(139, 92, 246, 0.2);
88
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
89
+ }
90
+
91
+ #clonereact-toolbar button {
92
+ padding: 8px 16px;
93
+ border-radius: 8px;
94
+ border: 1px solid rgba(255, 255, 255, 0.1);
95
+ background: rgba(255, 255, 255, 0.05);
96
+ color: white;
97
+ font-size: 14px;
98
+ font-weight: 500;
99
+ cursor: pointer;
100
+ transition: all 0.2s;
101
+ }
102
+
103
+ #clonereact-toolbar button:hover {
104
+ background: rgba(139, 92, 246, 0.2);
105
+ border-color: rgba(139, 92, 246, 0.5);
106
+ }
107
+
108
+ #clonereact-toolbar button.primary {
109
+ background: rgb(139, 92, 246);
110
+ border-color: rgb(139, 92, 246);
111
+ }
112
+
113
+ #clonereact-toolbar button.primary:hover {
114
+ background: rgb(124, 58, 237);
115
+ }
116
+
117
+ #clonereact-toolbar .info {
118
+ color: rgba(255, 255, 255, 0.7);
119
+ font-size: 13px;
120
+ }
121
+
122
+ #cluso-branding {
123
+ position: fixed;
124
+ top: 8px;
125
+ left: 80px;
126
+ z-index: 1000001;
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 8px;
130
+ padding: 4px 12px 4px 8px;
131
+ background: rgba(0, 0, 0, 0.5);
132
+ backdrop-filter: blur(10px);
133
+ border-radius: 8px;
134
+ pointer-events: none;
135
+ -webkit-app-region: no-drag;
136
+ }
137
+
138
+ #cluso-branding-logo {
139
+ width: 20px;
140
+ height: 20px;
141
+ border-radius: 4px;
142
+ object-fit: contain;
143
+ }
144
+
145
+ #cluso-branding-text {
146
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
147
+ font-size: 14px;
148
+ font-weight: 600;
149
+ color: white;
150
+ letter-spacing: -0.02em;
151
+ }
152
+ `;
153
+
154
+ (function() {
155
+ 'use strict';
156
+
157
+ const SCREENSHOT_PADDING_X = 0.2; // 20% horizontal padding for screenshot
158
+ const SCREENSHOT_PADDING_Y = 1.0; // 100% vertical padding for screenshot
159
+
160
+ let hoverOverlay = null;
161
+ let screenshotOverlay = null;
162
+ let selectionOverlay = null;
163
+ let selectedElement = null;
164
+ let currentHoveredElement = null;
165
+
166
+ /**
167
+ * Initialize inspector
168
+ */
169
+ function init() {
170
+ console.log('[CloneReact] Initializing inspector...');
171
+
172
+ // Inject styles
173
+ const styleEl = document.createElement('style');
174
+ styleEl.textContent = INSPECTOR_STYLES;
175
+ document.head.appendChild(styleEl);
176
+
177
+ // Create overlays
178
+ hoverOverlay = document.createElement('div');
179
+ hoverOverlay.id = 'cluso-hover-overlay';
180
+ hoverOverlay.setAttribute('data-clonereact-ui', '1');
181
+ document.body.appendChild(hoverOverlay);
182
+
183
+ screenshotOverlay = document.createElement('div');
184
+ screenshotOverlay.id = 'cluso-screenshot-overlay';
185
+ screenshotOverlay.setAttribute('data-clonereact-ui', '1');
186
+ screenshotOverlay.innerHTML = '<div class="cluso-overlay-label">Image</div>';
187
+ document.body.appendChild(screenshotOverlay);
188
+
189
+ selectionOverlay = document.createElement('div');
190
+ selectionOverlay.id = 'cluso-selection-overlay';
191
+ selectionOverlay.setAttribute('data-clonereact-ui', '1');
192
+ selectionOverlay.innerHTML = '<div class="cluso-overlay-label">Element</div>';
193
+ document.body.appendChild(selectionOverlay);
194
+
195
+ // Create branding
196
+ createBranding();
197
+
198
+ // Create toolbar
199
+ createToolbar();
200
+
201
+ // Event listeners
202
+ document.addEventListener('mousemove', handleMouseMove);
203
+ document.addEventListener('click', handleClick, true);
204
+ window.addEventListener('scroll', handleScroll, true);
205
+
206
+ console.log('[CloneReact] Inspector ready');
207
+ }
208
+
209
+ /**
210
+ * Create branding in top-left
211
+ */
212
+ function createBranding() {
213
+ const branding = document.createElement('div');
214
+ branding.id = 'cluso-branding';
215
+ branding.setAttribute('data-clonereact-ui', '1');
216
+
217
+ const iconUrl = window.__CLONEREACT_ICON_URL__ || '';
218
+
219
+ if (iconUrl) {
220
+ branding.innerHTML = `
221
+ <img id="cluso-branding-logo" src="${iconUrl}" alt="cluso" />
222
+ <div id="cluso-branding-text">cluso</div>
223
+ `;
224
+ } else {
225
+ // Fallback to "C" if no icon
226
+ branding.innerHTML = `
227
+ <div id="cluso-branding-logo" style="font-weight:700;font-size:16px;display:flex;align-items:center;justify-content:center;">C</div>
228
+ <div id="cluso-branding-text">cluso</div>
229
+ `;
230
+ }
231
+
232
+ document.body.appendChild(branding);
233
+ }
234
+
235
+ /**
236
+ * Handle scroll - reposition overlays
237
+ */
238
+ function handleScroll() {
239
+ if (selectedElement) {
240
+ positionScreenshotOverlay(selectedElement);
241
+ positionOverlay(selectionOverlay, selectedElement);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Create toolbar
247
+ */
248
+ function createToolbar() {
249
+ const toolbar = document.createElement('div');
250
+ toolbar.id = 'clonereact-toolbar';
251
+ toolbar.setAttribute('data-clonereact-ui', '1');
252
+
253
+ toolbar.innerHTML = `
254
+ <div class="info"><span id="selection-count">Hover to highlight, click to select</span></div>
255
+ <button id="btn-clear">Clear</button>
256
+ <button id="btn-extract" class="primary">Select</button>
257
+ <button id="btn-cancel">Cancel</button>
258
+ `;
259
+
260
+ document.body.appendChild(toolbar);
261
+
262
+ // Button handlers
263
+ document.getElementById('btn-clear').addEventListener('click', clearSelection);
264
+ document.getElementById('btn-extract').addEventListener('click', extractComponent);
265
+ document.getElementById('btn-cancel').addEventListener('click', cancel);
266
+ }
267
+
268
+ /**
269
+ * Position overlay over element
270
+ */
271
+ function positionOverlay(overlay, element) {
272
+ const rect = element.getBoundingClientRect();
273
+ overlay.style.top = `${rect.top + window.scrollY}px`;
274
+ overlay.style.left = `${rect.left + window.scrollX}px`;
275
+ overlay.style.width = `${rect.width}px`;
276
+ overlay.style.height = `${rect.height}px`;
277
+ }
278
+
279
+ /**
280
+ * Handle mouse move
281
+ */
282
+ function handleMouseMove(event) {
283
+ const target = event.target;
284
+
285
+ // Ignore our UI
286
+ if (target.closest('[data-clonereact-ui]')) {
287
+ hoverOverlay.classList.remove('visible');
288
+ return;
289
+ }
290
+
291
+ currentHoveredElement = target;
292
+ positionOverlay(hoverOverlay, target);
293
+ hoverOverlay.classList.add('visible');
294
+ }
295
+
296
+ /**
297
+ * Handle click
298
+ */
299
+ function handleClick(event) {
300
+ const target = event.target;
301
+
302
+ // Ignore our UI
303
+ if (target.closest('[data-clonereact-ui]')) {
304
+ return;
305
+ }
306
+
307
+ event.preventDefault();
308
+ event.stopPropagation();
309
+
310
+ // Clear previous selection
311
+ if (screenshotOverlay) {
312
+ screenshotOverlay.classList.remove('visible');
313
+ }
314
+ if (selectionOverlay) {
315
+ selectionOverlay.classList.remove('visible');
316
+ }
317
+
318
+ // Mark new selection
319
+ selectedElement = target;
320
+
321
+ // Position screenshot overlay (20% larger, blue)
322
+ positionScreenshotOverlay(target);
323
+ screenshotOverlay.classList.add('visible');
324
+
325
+ // Position element overlay (exact bounds, purple)
326
+ positionOverlay(selectionOverlay, target);
327
+ selectionOverlay.classList.add('visible');
328
+
329
+ // Hide hover
330
+ hoverOverlay.classList.remove('visible');
331
+
332
+ // Update count
333
+ updateSelectionCount();
334
+ }
335
+
336
+ /**
337
+ * Position screenshot overlay with padding (50% width, 60% height)
338
+ */
339
+ function positionScreenshotOverlay(element) {
340
+ const rect = element.getBoundingClientRect();
341
+ const paddingX = rect.width * SCREENSHOT_PADDING_X / 2;
342
+ const paddingY = rect.height * SCREENSHOT_PADDING_Y / 2;
343
+
344
+ screenshotOverlay.style.top = `${rect.top + window.scrollY - paddingY}px`;
345
+ screenshotOverlay.style.left = `${rect.left + window.scrollX - paddingX}px`;
346
+ screenshotOverlay.style.width = `${rect.width + (paddingX * 2)}px`;
347
+ screenshotOverlay.style.height = `${rect.height + (paddingY * 2)}px`;
348
+ }
349
+
350
+ /**
351
+ * Update selection count
352
+ */
353
+ function updateSelectionCount() {
354
+ const countEl = document.getElementById('selection-count');
355
+ if (selectedElement) {
356
+ const tagName = selectedElement.tagName.toLowerCase();
357
+ const className = selectedElement.className ? '.' + selectedElement.className.split(' ')[0] : '';
358
+ countEl.textContent = `Selected: ${tagName}${className}`;
359
+ } else {
360
+ countEl.textContent = 'Hover to highlight, click to select';
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Clear selection
366
+ */
367
+ function clearSelection() {
368
+ selectedElement = null;
369
+ if (screenshotOverlay) {
370
+ screenshotOverlay.classList.remove('visible');
371
+ }
372
+ if (selectionOverlay) {
373
+ selectionOverlay.classList.remove('visible');
374
+ }
375
+ updateSelectionCount();
376
+ }
377
+
378
+ /**
379
+ * Extract component
380
+ */
381
+ async function extractComponent() {
382
+ if (!selectedElement) {
383
+ alert('Please select an element first');
384
+ return;
385
+ }
386
+
387
+ console.log('[CloneReact] Extracting component...');
388
+
389
+ // Use the react-fiber extraction if available
390
+ const extraction = window.__extractReactContext ?
391
+ window.__extractReactContext(selectedElement) :
392
+ await extractBasicElement(selectedElement);
393
+
394
+ // Send to main process (will show generating phase and preview)
395
+ window.clonereact.sendExtraction({
396
+ success: true,
397
+ url: window.location.href,
398
+ timestamp: new Date().toISOString(),
399
+ extractions: [extraction]
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Capture element screenshot using Electron's native API with padding (50% width, 60% height)
405
+ */
406
+ async function captureScreenshot(element) {
407
+ try {
408
+ const rect = element.getBoundingClientRect();
409
+ const selector = getSelector(element);
410
+
411
+ // Calculate padding (50% width, 60% height)
412
+ const paddingX = rect.width * SCREENSHOT_PADDING_X / 2;
413
+ const paddingY = rect.height * SCREENSHOT_PADDING_Y / 2;
414
+
415
+ // Get bounds relative to page (not viewport) with padding
416
+ const bounds = {
417
+ x: Math.max(0, Math.round(rect.left + window.scrollX - paddingX)),
418
+ y: Math.max(0, Math.round(rect.top + window.scrollY - paddingY)),
419
+ width: Math.round(rect.width + (paddingX * 2)),
420
+ height: Math.round(rect.height + (paddingY * 2))
421
+ };
422
+
423
+ console.log('[CloneReact Renderer] Requesting screenshot with 20% width + 100% height padding for:', selector, bounds);
424
+
425
+ // Use Electron's native screenshot via IPC with bounds
426
+ const screenshot = await window.clonereact.captureElement(selector, bounds);
427
+
428
+ console.log('[CloneReact Renderer] IPC returned, screenshot:', screenshot ? screenshot.substring(0, 50) : 'NULL');
429
+
430
+ if (screenshot && screenshot.length > 1000) {
431
+ console.log('[CloneReact Renderer] Screenshot received:', screenshot.length, 'chars');
432
+ return screenshot;
433
+ } else {
434
+ console.error('[CloneReact Renderer] Screenshot too small or null:', screenshot ? screenshot.length : 0);
435
+ return null;
436
+ }
437
+ } catch (error) {
438
+ console.error('[CloneReact Renderer] Screenshot request failed:', error);
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Extract DOM tree structure recursively
445
+ */
446
+ function extractDOMTree(element, depth = 0, maxDepth = 3) {
447
+ if (!element || depth > maxDepth) return null;
448
+
449
+ // Skip script and style tags
450
+ if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE') return null;
451
+
452
+ const node = {
453
+ type: 'element',
454
+ tagName: element.tagName.toLowerCase(),
455
+ name: element.tagName.toLowerCase(),
456
+ props: {},
457
+ children: []
458
+ };
459
+
460
+ // Extract attributes as props
461
+ Array.from(element.attributes || []).forEach(attr => {
462
+ if (attr.name === 'class') {
463
+ node.props.className = attr.value;
464
+ } else if (attr.name !== 'style') {
465
+ node.props[attr.name] = attr.value;
466
+ }
467
+ });
468
+
469
+ // Extract state classes (hover, active, disabled, etc.)
470
+ const stateClasses = extractStateClasses(element);
471
+ if (stateClasses) {
472
+ node.states = stateClasses;
473
+ }
474
+
475
+ // Extract children
476
+ Array.from(element.childNodes || []).forEach(child => {
477
+ if (child.nodeType === 3) { // Text node
478
+ const text = child.textContent.trim();
479
+ if (text.length > 0 && text.length < 200) {
480
+ node.children.push({ type: 'text', value: text });
481
+ }
482
+ } else if (child.nodeType === 1) { // Element node
483
+ const childNode = extractDOMTree(child, depth + 1, maxDepth);
484
+ if (childNode) {
485
+ node.children.push(childNode);
486
+ }
487
+ }
488
+ });
489
+
490
+ return node;
491
+ }
492
+
493
+ /**
494
+ * Basic element extraction (fallback if React Fiber not available)
495
+ */
496
+ async function extractBasicElement(element) {
497
+ const rect = element.getBoundingClientRect();
498
+
499
+ console.log('[CloneReact Renderer] Starting extraction for:', element.tagName);
500
+
501
+ // Capture screenshot FIRST (returns Promise)
502
+ console.log('[CloneReact Renderer] About to call captureScreenshot...');
503
+ const screenshot = await captureScreenshot(element);
504
+ console.log('[CloneReact Renderer] captureScreenshot returned, type:', typeof screenshot);
505
+ console.log('[CloneReact Renderer] Screenshot value:', screenshot ? screenshot.substring(0, 100) : 'NULL OR UNDEFINED');
506
+ console.log('[CloneReact Renderer] Screenshot length:', screenshot ? screenshot.length : 0);
507
+
508
+ // Extract DOM tree
509
+ const domTree = extractDOMTree(element, 0, 5);
510
+
511
+ const extraction = {
512
+ selector: getSelector(element),
513
+ isReact: false,
514
+ reactVersion: null,
515
+ component: domTree,
516
+ html: element.outerHTML.substring(0, 10000),
517
+ styles: getComputedStylesObj(element),
518
+ screenshot: screenshot, // Assign the awaited screenshot
519
+ dimensions: {
520
+ width: rect.width,
521
+ height: rect.height,
522
+ top: rect.top,
523
+ left: rect.left
524
+ },
525
+ meta: {
526
+ tagName: element.tagName.toLowerCase(),
527
+ id: element.id,
528
+ classes: Array.from(element.classList)
529
+ }
530
+ };
531
+
532
+ console.log('[CloneReact Renderer] Built extraction object');
533
+ console.log('[CloneReact Renderer] extraction.screenshot exists?', !!extraction.screenshot);
534
+ console.log('[CloneReact Renderer] extraction.screenshot length:', extraction.screenshot ? extraction.screenshot.length : 0);
535
+
536
+ return extraction;
537
+ }
538
+
539
+ /**
540
+ * Get CSS selector for element
541
+ */
542
+ function getSelector(element) {
543
+ if (element.id) return '#' + element.id;
544
+
545
+ const path = [];
546
+ let current = element;
547
+
548
+ while (current && current !== document.body) {
549
+ let selector = current.tagName.toLowerCase();
550
+
551
+ if (current.className) {
552
+ const classes = current.className.split(' ').filter(c => c.trim());
553
+ if (classes.length > 0) {
554
+ selector += '.' + classes[0];
555
+ }
556
+ }
557
+
558
+ path.unshift(selector);
559
+ current = current.parentElement;
560
+ }
561
+
562
+ return path.join(' > ');
563
+ }
564
+
565
+ /**
566
+ * Get computed styles as object
567
+ */
568
+ function getComputedStylesObj(element) {
569
+ const computed = window.getComputedStyle(element);
570
+ const styles = {};
571
+
572
+ const props = [
573
+ 'display', 'position', 'width', 'height', 'margin', 'padding',
574
+ 'background', 'backgroundColor', 'border', 'borderRadius',
575
+ 'color', 'fontSize', 'fontFamily', 'fontWeight',
576
+ 'flex', 'flexDirection', 'alignItems', 'justifyContent', 'gap'
577
+ ];
578
+
579
+ props.forEach(prop => {
580
+ const value = computed.getPropertyValue(prop);
581
+ if (value && value !== 'none' && value !== 'normal' && value !== '0px') {
582
+ styles[prop] = value;
583
+ }
584
+ });
585
+
586
+ return styles;
587
+ }
588
+
589
+ /**
590
+ * Extract state-based classes (hover, active, disabled, focus, etc.)
591
+ */
592
+ function extractStateClasses(element) {
593
+ const className = element.className || '';
594
+ if (typeof className !== 'string') return {};
595
+
596
+ const classes = className.split(' ').filter(c => c.trim());
597
+ const states = {
598
+ hover: [],
599
+ active: [],
600
+ focus: [],
601
+ disabled: [],
602
+ checked: [],
603
+ selected: []
604
+ };
605
+
606
+ const statePattern = /^(hover|active|focus|disabled|checked|selected):(.+)$/;
607
+
608
+ classes.forEach(cls => {
609
+ const match = cls.match(statePattern);
610
+ if (match) {
611
+ const [, state, style] = match;
612
+ if (states[state]) {
613
+ states[state].push(style);
614
+ }
615
+ }
616
+ });
617
+
618
+ // Filter out empty state arrays
619
+ Object.keys(states).forEach(key => {
620
+ if (states[key].length === 0) {
621
+ delete states[key];
622
+ }
623
+ });
624
+
625
+ return Object.keys(states).length > 0 ? states : null;
626
+ }
627
+
628
+ /**
629
+ * Cancel extraction
630
+ */
631
+ function cancel() {
632
+ window.clonereact.cancel();
633
+ }
634
+
635
+ // Export API
636
+ window.__CLONEREACT_INSPECTOR__ = {
637
+ init,
638
+ clearSelection,
639
+ extractComponent,
640
+ cancel
641
+ };
642
+
643
+ console.log('[CloneReact] Inspector module loaded');
644
+ })();
package/main.js ADDED
@@ -0,0 +1,153 @@
1
+ const { app, BrowserWindow, ipcMain } = require('electron');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ let mainWindow = null;
6
+
7
+ function createWindow(url) {
8
+ mainWindow = new BrowserWindow({
9
+ width: 1440,
10
+ height: 900,
11
+ titleBarStyle: 'hiddenInset', // Hide title bar, keep traffic lights
12
+ webPreferences: {
13
+ nodeIntegration: false,
14
+ contextIsolation: true,
15
+ preload: path.join(__dirname, 'preload.js'),
16
+ webSecurity: true,
17
+ },
18
+ backgroundColor: '#1a1a1a',
19
+ show: false,
20
+ title: 'Cluso Inspector'
21
+ });
22
+
23
+ mainWindow.loadURL(url);
24
+
25
+ mainWindow.once('ready-to-show', () => {
26
+ mainWindow.show();
27
+
28
+ mainWindow.webContents.on('did-finish-load', () => {
29
+ setTimeout(() => {
30
+ injectInspector();
31
+ }, 1000);
32
+ });
33
+ });
34
+
35
+ mainWindow.on('closed', () => {
36
+ mainWindow = null;
37
+ });
38
+ }
39
+
40
+ function injectInspector() {
41
+ if (!mainWindow) return;
42
+
43
+ const inspectorScript = fs.readFileSync(
44
+ path.join(__dirname, 'cluso-inspector.js'),
45
+ 'utf8'
46
+ );
47
+
48
+ // Load cluso icon as base64 data URL
49
+ const iconPath = path.join(__dirname, 'assets', 'cluso-icon.png');
50
+ let iconDataUrl = '';
51
+ try {
52
+ const iconBuffer = fs.readFileSync(iconPath);
53
+ iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
54
+ } catch (error) {
55
+ console.warn('[CloneReact] Could not load cluso icon:', error.message);
56
+ }
57
+
58
+ mainWindow.webContents.executeJavaScript(inspectorScript)
59
+ .then(() => {
60
+ return mainWindow.webContents.executeJavaScript(`
61
+ window.__CLONEREACT_ICON_URL__ = ${JSON.stringify(iconDataUrl)};
62
+ window.__CLONEREACT_INSPECTOR__.init();
63
+ `);
64
+ })
65
+ .then(() => {
66
+ console.log('[CloneReact] Inspector ready');
67
+ })
68
+ .catch((error) => {
69
+ console.error('[CloneReact] Injection failed:', error);
70
+ });
71
+ }
72
+
73
+ // IPC: Screenshot capture
74
+ ipcMain.handle('capture-element', async (event, selector, bounds) => {
75
+ if (!mainWindow) return null;
76
+
77
+ try {
78
+ // Capture only the element's bounding rectangle
79
+ const rect = bounds ? {
80
+ x: bounds.x,
81
+ y: bounds.y,
82
+ width: bounds.width,
83
+ height: bounds.height
84
+ } : undefined;
85
+
86
+ const image = await mainWindow.webContents.capturePage(rect);
87
+ const base64 = image.toPNG().toString('base64');
88
+ const dataUrl = `data:image/png;base64,${base64}`;
89
+
90
+ console.log('[CloneReact] Screenshot captured:', bounds ? `${bounds.width}x${bounds.height}` : 'full page', dataUrl.length, 'chars');
91
+ return dataUrl;
92
+ } catch (error) {
93
+ console.error('[CloneReact] Screenshot failed:', error);
94
+ return null;
95
+ }
96
+ });
97
+
98
+ // IPC: Extraction complete
99
+ ipcMain.handle('extraction-complete', async (event, data) => {
100
+ const outputPath = process.argv[3] || '/tmp/clonereact-output-' + process.pid + '.json';
101
+ const language = process.argv[4] || 'typescript';
102
+
103
+ // Add language to output
104
+ const outputData = {
105
+ ...data,
106
+ language
107
+ };
108
+
109
+ try {
110
+ fs.writeFileSync(outputPath, JSON.stringify(outputData, null, 2));
111
+ console.log('[CloneReact] Extraction saved:', outputPath);
112
+ } catch (error) {
113
+ console.error('[CloneReact] Failed to save:', error);
114
+ }
115
+
116
+ // Close and exit
117
+ setTimeout(() => {
118
+ if (mainWindow) mainWindow.close();
119
+ app.quit();
120
+ }, 500);
121
+
122
+ return { success: true };
123
+ });
124
+
125
+ // IPC: Cancel
126
+ ipcMain.handle('extraction-cancelled', async () => {
127
+ if (mainWindow) mainWindow.close();
128
+ app.quit();
129
+ process.exit(1);
130
+ });
131
+
132
+ // App lifecycle
133
+ app.whenReady().then(() => {
134
+ const targetURL = process.argv[2];
135
+
136
+ if (!targetURL) {
137
+ console.error('Usage: electron main.js <url> <output-json-path>');
138
+ app.quit();
139
+ process.exit(1);
140
+ return;
141
+ }
142
+
143
+ createWindow(targetURL);
144
+ });
145
+
146
+ app.on('window-all-closed', () => {
147
+ app.quit();
148
+ });
149
+
150
+ process.on('uncaughtException', (error) => {
151
+ console.error('[CloneReact] Error:', error);
152
+ process.exit(1);
153
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "cluso-inspector",
3
+ "version": "1.0.0",
4
+ "description": "Visual element selector for extracting HTML and screenshots from websites",
5
+ "main": "main.js",
6
+ "bin": {
7
+ "cluso-inspector": "./bin/cluso-inspector.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "assets/",
12
+ "main.js",
13
+ "preload.js",
14
+ "cluso-inspector.js",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "start": "electron .",
19
+ "prepublishOnly": "npm pack --dry-run"
20
+ },
21
+ "keywords": [
22
+ "electron",
23
+ "inspector",
24
+ "component",
25
+ "extraction",
26
+ "html",
27
+ "screenshot",
28
+ "react",
29
+ "clonereact",
30
+ "web-scraping"
31
+ ],
32
+ "author": "Jason Kneen",
33
+ "license": "MIT",
34
+ "homepage": "https://github.com/jasonkneen/clone-react-skill#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/jasonkneen/clone-react-skill/issues"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/jasonkneen/clone-react-skill.git"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ },
45
+ "dependencies": {
46
+ "electron": "^28.0.0"
47
+ }
48
+ }
package/preload.js ADDED
@@ -0,0 +1,30 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ // Expose safe IPC methods to renderer
4
+ contextBridge.exposeInMainWorld('clonereact', {
5
+ // Send extraction data back to main process
6
+ sendExtraction: (data) => ipcRenderer.invoke('extraction-complete', data),
7
+
8
+ // Cancel extraction
9
+ cancel: () => ipcRenderer.invoke('extraction-cancelled'),
10
+
11
+ // Request screenshot of element via main process
12
+ captureElement: (selector, bounds) => ipcRenderer.invoke('capture-element', selector, bounds),
13
+
14
+ // Preview phase actions
15
+ exportFiles: () => ipcRenderer.invoke('export-files'),
16
+ openFolder: () => ipcRenderer.invoke('open-folder'),
17
+ startOver: () => ipcRenderer.invoke('start-over'),
18
+ close: () => ipcRenderer.invoke('close-app'),
19
+
20
+ // Listen for generation complete event from main
21
+ onGenerationComplete: (callback) => {
22
+ ipcRenderer.on('generation-complete', (event, data) => callback(data));
23
+ },
24
+
25
+ // Get initial config
26
+ getConfig: () => ({
27
+ version: '1.0.0',
28
+ maxDepth: 5,
29
+ }),
30
+ });