@ytspar/sweetlink 1.10.0 → 1.11.1
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 +32 -23
- package/claude-skills/screenshot/SKILL.md +381 -0
- package/dist/browser/SweetlinkBridge.d.ts.map +1 -1
- package/dist/browser/SweetlinkBridge.js +1 -1
- package/dist/browser/SweetlinkBridge.js.map +1 -1
- package/dist/browser/commands/exec.d.ts.map +1 -1
- package/dist/browser/commands/exec.js +5 -2
- package/dist/browser/commands/exec.js.map +1 -1
- package/dist/browser/commands/index.d.ts +2 -2
- package/dist/browser/commands/index.d.ts.map +1 -1
- package/dist/browser/commands/index.js +2 -2
- package/dist/browser/commands/index.js.map +1 -1
- package/dist/browser/commands/outline.js.map +1 -1
- package/dist/browser/commands/schema.d.ts.map +1 -1
- package/dist/browser/commands/schema.js +10 -10
- package/dist/browser/commands/schema.js.map +1 -1
- package/dist/browser/commands/screenshot.d.ts.map +1 -1
- package/dist/browser/commands/screenshot.js +21 -3
- package/dist/browser/commands/screenshot.js.map +1 -1
- package/dist/browser/consoleCapture.d.ts.map +1 -1
- package/dist/browser/consoleCapture.js +3 -1
- package/dist/browser/consoleCapture.js.map +1 -1
- package/dist/browser.js +2 -3
- package/dist/browser.js.map +1 -1
- package/dist/cdp.d.ts +1 -1
- package/dist/cdp.d.ts.map +1 -1
- package/dist/cdp.js +1 -1
- package/dist/cdp.js.map +1 -1
- package/dist/cli/sweetlink.js +479 -256
- package/dist/cli/sweetlink.js.map +1 -1
- package/dist/playwright.d.ts.map +1 -1
- package/dist/playwright.js.map +1 -1
- package/dist/server/handlers/index.d.ts +2 -2
- package/dist/server/handlers/index.d.ts.map +1 -1
- package/dist/server/handlers/index.js +1 -1
- package/dist/server/handlers/index.js.map +1 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewportUtils.js.map +1 -1
- package/package.json +12 -3
- package/scripts/setup-claude-context.mjs +105 -68
package/dist/cli/sweetlink.js
CHANGED
|
@@ -176,29 +176,91 @@ async function waitForServer(url, timeout = SERVER_READY_TIMEOUT) {
|
|
|
176
176
|
}
|
|
177
177
|
throw new Error(`Server not ready after ${timeout}ms: ${lastError?.message || 'Connection refused'}`);
|
|
178
178
|
}
|
|
179
|
-
async function sendCommand(command) {
|
|
179
|
+
async function sendCommand(command, timeoutMs = DEFAULT_TIMEOUT) {
|
|
180
180
|
return new Promise((resolve, reject) => {
|
|
181
181
|
const ws = new WebSocket(WS_URL);
|
|
182
|
-
const
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
183
|
ws.close();
|
|
184
184
|
reject(new Error('Command timeout - is the dev server running?'));
|
|
185
|
-
},
|
|
185
|
+
}, timeoutMs);
|
|
186
186
|
ws.on('open', () => {
|
|
187
187
|
ws.send(JSON.stringify(command));
|
|
188
188
|
});
|
|
189
189
|
ws.on('message', (data) => {
|
|
190
|
-
clearTimeout(
|
|
190
|
+
clearTimeout(timer);
|
|
191
191
|
const response = JSON.parse(data.toString());
|
|
192
192
|
ws.close();
|
|
193
193
|
resolve(response);
|
|
194
194
|
});
|
|
195
195
|
ws.on('error', (error) => {
|
|
196
|
-
clearTimeout(
|
|
196
|
+
clearTimeout(timer);
|
|
197
197
|
ws.close();
|
|
198
198
|
reject(error);
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Compare two URLs ignoring trailing slashes.
|
|
204
|
+
* Exported-style name for testability (re-implemented in tests).
|
|
205
|
+
*/
|
|
206
|
+
function urlsMatch(a, b) {
|
|
207
|
+
return a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
|
|
208
|
+
}
|
|
209
|
+
const NAVIGATE_POLL_INTERVAL = 500; // ms between reconnection polls
|
|
210
|
+
const NAVIGATE_DEFAULT_TIMEOUT = 10000; // 10s default
|
|
211
|
+
/**
|
|
212
|
+
* Navigate the connected browser to a URL via WebSocket exec-js.
|
|
213
|
+
* After navigation the page reloads, dropping the WS connection.
|
|
214
|
+
* We poll with short-timeout exec-js commands until devbar reconnects.
|
|
215
|
+
*
|
|
216
|
+
* @returns true if the browser is on the target URL, false if no browser
|
|
217
|
+
* is connected or reconnection timed out.
|
|
218
|
+
*/
|
|
219
|
+
async function navigateBrowser(url, timeout = NAVIGATE_DEFAULT_TIMEOUT) {
|
|
220
|
+
// 1. Check current URL
|
|
221
|
+
try {
|
|
222
|
+
const response = await sendCommand({ type: 'exec-js', code: 'window.location.href' }, 3000);
|
|
223
|
+
if (response.success && response.data != null) {
|
|
224
|
+
const currentUrl = String(response.data.result ?? response.data);
|
|
225
|
+
if (urlsMatch(currentUrl, url)) {
|
|
226
|
+
console.log(`[Sweetlink] Browser already on ${url}`);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// No browser connected — caller should escalate
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
// 2. Navigate
|
|
236
|
+
console.log(`[Sweetlink] Navigating browser to ${url}`);
|
|
237
|
+
try {
|
|
238
|
+
// Fire-and-forget: the page will unload, so the response may never arrive
|
|
239
|
+
await sendCommand({ type: 'exec-js', code: `window.location.href = ${JSON.stringify(url)}` }, 3000).catch(() => { });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Expected — page unloaded before response
|
|
243
|
+
}
|
|
244
|
+
// 3. Poll for reconnection
|
|
245
|
+
const startTime = Date.now();
|
|
246
|
+
while (Date.now() - startTime < timeout) {
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, NAVIGATE_POLL_INTERVAL));
|
|
248
|
+
try {
|
|
249
|
+
const response = await sendCommand({ type: 'exec-js', code: 'window.location.href' }, 3000);
|
|
250
|
+
if (response.success) {
|
|
251
|
+
// Give devbar time to fully initialize after page load
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
253
|
+
console.log(`[Sweetlink] Browser reconnected on new page`);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Still reconnecting — keep polling
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
console.warn(`[Sweetlink] Browser did not reconnect within ${timeout}ms`);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
202
264
|
const WAIT_FOR_POLL_INTERVAL = 200; // ms between DOM polls
|
|
203
265
|
const WAIT_FOR_DEFAULT_TIMEOUT = 10000; // 10s default
|
|
204
266
|
/**
|
|
@@ -230,6 +292,30 @@ async function waitForSelector(selector, timeout = WAIT_FOR_DEFAULT_TIMEOUT) {
|
|
|
230
292
|
}
|
|
231
293
|
throw new Error(`Timeout: selector "${selector}" not found after ${timeout}ms`);
|
|
232
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Run Playwright screenshot, report success, and return a ScreenshotData result.
|
|
297
|
+
* Consolidates the repeated ensureDir + screenshotViaPlaywright + reportSuccess + return pattern.
|
|
298
|
+
*/
|
|
299
|
+
async function takePlaywrightScreenshot(options, method) {
|
|
300
|
+
const outputPath = options.output || getDefaultScreenshotPath();
|
|
301
|
+
ensureDir(outputPath);
|
|
302
|
+
const result = await screenshotViaPlaywright({
|
|
303
|
+
selector: options.selector,
|
|
304
|
+
output: outputPath,
|
|
305
|
+
fullPage: options.fullPage,
|
|
306
|
+
viewport: options.viewport,
|
|
307
|
+
hover: options.hover,
|
|
308
|
+
url: options.url,
|
|
309
|
+
});
|
|
310
|
+
reportScreenshotSuccess(outputPath, result.width, result.height, method, options.selector);
|
|
311
|
+
return {
|
|
312
|
+
path: getRelativePath(outputPath),
|
|
313
|
+
width: result.width,
|
|
314
|
+
height: result.height,
|
|
315
|
+
method,
|
|
316
|
+
selector: options.selector,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
233
319
|
async function screenshot(options) {
|
|
234
320
|
// Convert --width/--height to viewport format if provided
|
|
235
321
|
if (options.width && !options.viewport) {
|
|
@@ -264,26 +350,21 @@ async function screenshot(options) {
|
|
|
264
350
|
// Check if CDP is available (unless force WS is specified)
|
|
265
351
|
// Hover requires CDP/Playwright
|
|
266
352
|
const requiresCDP = options.forceCDP || options.hover;
|
|
353
|
+
const playwrightOpts = {
|
|
354
|
+
selector: options.selector,
|
|
355
|
+
output: options.output,
|
|
356
|
+
fullPage: options.fullPage,
|
|
357
|
+
viewport: options.viewport,
|
|
358
|
+
hover: options.hover,
|
|
359
|
+
url: options.url,
|
|
360
|
+
};
|
|
267
361
|
// If we need CDP/Playwright (for hover or force-cdp), or if CDP is available, use Playwright
|
|
268
362
|
// Playwright will auto-launch if CDP is not available
|
|
269
363
|
const shouldTryPlaywright = requiresCDP || (!options.forceWS && (await detectCDP()));
|
|
270
364
|
if (shouldTryPlaywright) {
|
|
271
365
|
console.log('[Sweetlink] Using Playwright for screenshot');
|
|
272
|
-
// Determine output path - use default if not specified
|
|
273
|
-
const outputPath = options.output || getDefaultScreenshotPath();
|
|
274
|
-
ensureDir(outputPath);
|
|
275
366
|
try {
|
|
276
|
-
|
|
277
|
-
const result = await screenshotViaPlaywright({
|
|
278
|
-
selector: options.selector,
|
|
279
|
-
output: outputPath,
|
|
280
|
-
fullPage: options.fullPage,
|
|
281
|
-
viewport: options.viewport,
|
|
282
|
-
hover: options.hover,
|
|
283
|
-
url: options.url,
|
|
284
|
-
});
|
|
285
|
-
reportScreenshotSuccess(outputPath, result.width, result.height, 'Playwright (Auto-launch/CDP)', options.selector);
|
|
286
|
-
return { path: getRelativePath(outputPath), width: result.width, height: result.height, method: 'Playwright (Auto-launch/CDP)', selector: options.selector };
|
|
367
|
+
return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (Auto-launch/CDP)');
|
|
287
368
|
}
|
|
288
369
|
catch (error) {
|
|
289
370
|
if (options.forceCDP) {
|
|
@@ -295,6 +376,19 @@ async function screenshot(options) {
|
|
|
295
376
|
}
|
|
296
377
|
}
|
|
297
378
|
// Fall back to WebSocket method
|
|
379
|
+
// Navigate the connected browser if --url is provided
|
|
380
|
+
if (options.url) {
|
|
381
|
+
const navigated = await navigateBrowser(options.url);
|
|
382
|
+
if (!navigated) {
|
|
383
|
+
if (options.forceWS) {
|
|
384
|
+
console.error('[Sweetlink] Could not navigate browser to', options.url);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
// Auto-escalate to Playwright (opens browser, navigates, screenshots)
|
|
388
|
+
console.log('[Sweetlink] No browser for navigation — escalating to Playwright');
|
|
389
|
+
return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (auto-escalation)');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
298
392
|
console.log('[Sweetlink] Using WebSocket for screenshot');
|
|
299
393
|
const command = {
|
|
300
394
|
type: 'screenshot',
|
|
@@ -302,6 +396,7 @@ async function screenshot(options) {
|
|
|
302
396
|
options: {
|
|
303
397
|
fullPage: options.fullPage,
|
|
304
398
|
a11y: options.a11y,
|
|
399
|
+
scale: 0.5,
|
|
305
400
|
},
|
|
306
401
|
};
|
|
307
402
|
try {
|
|
@@ -311,19 +406,8 @@ async function screenshot(options) {
|
|
|
311
406
|
// This happens after dev server restart when browser page hasn't been refreshed
|
|
312
407
|
if (response.error?.includes('No browser client connected')) {
|
|
313
408
|
console.log('[Sweetlink] No browser client - auto-escalating to Playwright');
|
|
314
|
-
const outputPath = options.output || getDefaultScreenshotPath();
|
|
315
|
-
ensureDir(outputPath);
|
|
316
409
|
try {
|
|
317
|
-
|
|
318
|
-
selector: options.selector,
|
|
319
|
-
output: outputPath,
|
|
320
|
-
fullPage: options.fullPage,
|
|
321
|
-
viewport: options.viewport,
|
|
322
|
-
hover: options.hover,
|
|
323
|
-
url: options.url,
|
|
324
|
-
});
|
|
325
|
-
reportScreenshotSuccess(outputPath, result.width, result.height, 'Playwright (auto-escalation from WebSocket failure)', options.selector);
|
|
326
|
-
return { path: getRelativePath(outputPath), width: result.width, height: result.height, method: 'Playwright (auto-escalation)', selector: options.selector };
|
|
410
|
+
return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (auto-escalation)');
|
|
327
411
|
}
|
|
328
412
|
catch (playwrightError) {
|
|
329
413
|
console.error('[Sweetlink] Playwright fallback also failed:', playwrightError instanceof Error ? playwrightError.message : playwrightError);
|
|
@@ -340,7 +424,13 @@ async function screenshot(options) {
|
|
|
340
424
|
const base64Data = data.screenshot.replace(/^data:image\/png;base64,/, '');
|
|
341
425
|
fs.writeFileSync(outputPath, Buffer.from(base64Data, 'base64'));
|
|
342
426
|
reportScreenshotSuccess(outputPath, data.width, data.height, 'WebSocket (html2canvas)', data.selector);
|
|
343
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
path: getRelativePath(outputPath),
|
|
429
|
+
width: data.width,
|
|
430
|
+
height: data.height,
|
|
431
|
+
method: 'WebSocket (html2canvas)',
|
|
432
|
+
selector: data.selector,
|
|
433
|
+
};
|
|
344
434
|
}
|
|
345
435
|
catch (error) {
|
|
346
436
|
console.error('[Sweetlink] Error:', error instanceof Error ? error.message : error);
|
|
@@ -403,7 +493,11 @@ async function queryDOM(options) {
|
|
|
403
493
|
console.log('\nElements:');
|
|
404
494
|
console.log(JSON.stringify(data.results, null, 2));
|
|
405
495
|
}
|
|
406
|
-
return {
|
|
496
|
+
return {
|
|
497
|
+
count: data.count,
|
|
498
|
+
results: data.results,
|
|
499
|
+
property: options.property,
|
|
500
|
+
};
|
|
407
501
|
}
|
|
408
502
|
catch (error) {
|
|
409
503
|
console.error('[Sweetlink] Error:', error instanceof Error ? error.message : error);
|
|
@@ -441,6 +535,38 @@ function deduplicateLogs(logs) {
|
|
|
441
535
|
return b.count - a.count;
|
|
442
536
|
});
|
|
443
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Render console logs to stdout in human-readable text format with ANSI colors.
|
|
540
|
+
*/
|
|
541
|
+
function renderLogsAsText(logs, dedupedLogs) {
|
|
542
|
+
const LEVEL_COLORS = {
|
|
543
|
+
error: '\x1b[31m',
|
|
544
|
+
warn: '\x1b[33m',
|
|
545
|
+
info: '\x1b[36m',
|
|
546
|
+
log: '\x1b[37m',
|
|
547
|
+
};
|
|
548
|
+
const reset = '\x1b[0m';
|
|
549
|
+
console.log(`[Sweetlink] ✓ Found ${logs.length} log entries${dedupedLogs ? ` (${dedupedLogs.length} unique)` : ''}`);
|
|
550
|
+
if (logs.length === 0) {
|
|
551
|
+
console.log(' No logs found');
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
console.log('\nConsole Logs:');
|
|
555
|
+
if (dedupedLogs) {
|
|
556
|
+
dedupedLogs.forEach((log) => {
|
|
557
|
+
const levelColor = LEVEL_COLORS[log.level] || '\x1b[37m';
|
|
558
|
+
const countStr = log.count > 1 ? ` (×${log.count})` : '';
|
|
559
|
+
console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset}${countStr} - ${log.message}`);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
logs.forEach((log) => {
|
|
564
|
+
const levelColor = LEVEL_COLORS[log.level] || '\x1b[37m';
|
|
565
|
+
const time = new Date(log.timestamp).toLocaleTimeString();
|
|
566
|
+
console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset} ${time} - ${log.message}`);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
444
570
|
async function getLogs(options) {
|
|
445
571
|
if (options.format === 'text') {
|
|
446
572
|
console.log('[Sweetlink] Getting console logs...');
|
|
@@ -471,7 +597,13 @@ async function getLogs(options) {
|
|
|
471
597
|
else {
|
|
472
598
|
console.log(jsonStr);
|
|
473
599
|
}
|
|
474
|
-
return {
|
|
600
|
+
return {
|
|
601
|
+
total: logs.length,
|
|
602
|
+
format: 'json',
|
|
603
|
+
deduped: !!options.dedupe,
|
|
604
|
+
logs: processedLogs,
|
|
605
|
+
outputPath: options.output,
|
|
606
|
+
};
|
|
475
607
|
}
|
|
476
608
|
// Summary format - deduplicated with counts, optimized for LLM context
|
|
477
609
|
if (options.format === 'summary') {
|
|
@@ -500,44 +632,24 @@ async function getLogs(options) {
|
|
|
500
632
|
else {
|
|
501
633
|
console.log(summaryStr);
|
|
502
634
|
}
|
|
503
|
-
return {
|
|
635
|
+
return {
|
|
636
|
+
total: logs.length,
|
|
637
|
+
format: 'summary',
|
|
638
|
+
deduped: true,
|
|
639
|
+
logs: deduped,
|
|
640
|
+
outputPath: options.output,
|
|
641
|
+
};
|
|
504
642
|
}
|
|
505
643
|
// Default text format
|
|
506
644
|
const displayLogs = options.dedupe ? deduplicateLogs(logs) : null;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
info: '\x1b[36m',
|
|
516
|
-
log: '\x1b[37m',
|
|
517
|
-
}[log.level] || '\x1b[37m';
|
|
518
|
-
const reset = '\x1b[0m';
|
|
519
|
-
const countStr = log.count > 1 ? ` (×${log.count})` : '';
|
|
520
|
-
console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset}${countStr} - ${log.message}`);
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
logs.forEach((log) => {
|
|
525
|
-
const levelColor = {
|
|
526
|
-
error: '\x1b[31m',
|
|
527
|
-
warn: '\x1b[33m',
|
|
528
|
-
info: '\x1b[36m',
|
|
529
|
-
log: '\x1b[37m',
|
|
530
|
-
}[log.level] || '\x1b[37m';
|
|
531
|
-
const reset = '\x1b[0m';
|
|
532
|
-
const time = new Date(log.timestamp).toLocaleTimeString();
|
|
533
|
-
console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset} ${time} - ${log.message}`);
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
else {
|
|
538
|
-
console.log(' No logs found');
|
|
539
|
-
}
|
|
540
|
-
return { total: logs.length, format: 'text', deduped: !!options.dedupe, logs: displayLogs || logs, outputPath: undefined };
|
|
645
|
+
renderLogsAsText(logs, displayLogs);
|
|
646
|
+
return {
|
|
647
|
+
total: logs.length,
|
|
648
|
+
format: 'text',
|
|
649
|
+
deduped: !!options.dedupe,
|
|
650
|
+
logs: displayLogs || logs,
|
|
651
|
+
outputPath: undefined,
|
|
652
|
+
};
|
|
541
653
|
}
|
|
542
654
|
catch (error) {
|
|
543
655
|
console.error('[Sweetlink] Error:', error instanceof Error ? error.message : error);
|
|
@@ -653,28 +765,29 @@ async function execJS(options) {
|
|
|
653
765
|
process.exit(1);
|
|
654
766
|
}
|
|
655
767
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
768
|
+
/**
|
|
769
|
+
* Generate JavaScript code that finds elements and clicks the one at the given index.
|
|
770
|
+
* Parameterized by the element-finding strategy: text content search or CSS selector.
|
|
771
|
+
*/
|
|
772
|
+
function generateClickCode(strategy, index) {
|
|
773
|
+
// The element-finding expression differs, but the bounds-check + click + return is shared
|
|
774
|
+
const escapedSelector = JSON.stringify(strategy.selector);
|
|
775
|
+
let findExpression;
|
|
776
|
+
let notFoundMsg;
|
|
777
|
+
if (strategy.type === 'text') {
|
|
778
|
+
const escapedText = JSON.stringify(strategy.text);
|
|
779
|
+
findExpression = `Array.from(document.querySelectorAll(${escapedSelector})).filter(el => el.textContent?.includes(${escapedText}))`;
|
|
780
|
+
notFoundMsg = `"No element found with text: " + ${escapedText}`;
|
|
661
781
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
// Escape for JSON to safely embed in JavaScript
|
|
668
|
-
const escapedText = JSON.stringify(text);
|
|
669
|
-
const escapedSelector = JSON.stringify(baseSelector);
|
|
670
|
-
description = selector ? `"${text}" within ${selector}` : `"${text}"`;
|
|
671
|
-
clickCode = `
|
|
782
|
+
else {
|
|
783
|
+
findExpression = `Array.from(document.querySelectorAll(${escapedSelector}))`;
|
|
784
|
+
notFoundMsg = `"No element found matching: " + ${escapedSelector}`;
|
|
785
|
+
}
|
|
786
|
+
return `
|
|
672
787
|
(() => {
|
|
673
|
-
const
|
|
674
|
-
const elements = Array.from(document.querySelectorAll(${escapedSelector}))
|
|
675
|
-
.filter(el => el.textContent?.includes(searchText));
|
|
788
|
+
const elements = ${findExpression};
|
|
676
789
|
if (elements.length === 0) {
|
|
677
|
-
return { success: false, error:
|
|
790
|
+
return { success: false, error: ${notFoundMsg} };
|
|
678
791
|
}
|
|
679
792
|
const target = elements[${index}];
|
|
680
793
|
if (!target) {
|
|
@@ -684,25 +797,23 @@ async function click(options) {
|
|
|
684
797
|
return { success: true, clicked: target.tagName + (target.className ? "." + target.className.split(" ")[0] : ""), found: elements.length };
|
|
685
798
|
})()
|
|
686
799
|
`;
|
|
800
|
+
}
|
|
801
|
+
async function click(options) {
|
|
802
|
+
const { selector, text, index = 0 } = options;
|
|
803
|
+
if (!selector && !text) {
|
|
804
|
+
console.error('[Sweetlink] Error: Either --selector or --text is required');
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
let clickCode;
|
|
808
|
+
let description;
|
|
809
|
+
if (text) {
|
|
810
|
+
const baseSelector = selector || '*';
|
|
811
|
+
description = selector ? `"${text}" within ${selector}` : `"${text}"`;
|
|
812
|
+
clickCode = generateClickCode({ type: 'text', text, selector: baseSelector }, index);
|
|
687
813
|
}
|
|
688
814
|
else {
|
|
689
|
-
// Find element by selector
|
|
690
|
-
const escapedSelector = JSON.stringify(selector);
|
|
691
815
|
description = `${selector}${index > 0 ? ` [${index}]` : ''}`;
|
|
692
|
-
clickCode =
|
|
693
|
-
(() => {
|
|
694
|
-
const elements = document.querySelectorAll(${escapedSelector});
|
|
695
|
-
if (elements.length === 0) {
|
|
696
|
-
return { success: false, error: "No element found matching: " + ${escapedSelector} };
|
|
697
|
-
}
|
|
698
|
-
const target = elements[${index}];
|
|
699
|
-
if (!target) {
|
|
700
|
-
return { success: false, error: "Index ${index} out of bounds, found " + elements.length + " elements" };
|
|
701
|
-
}
|
|
702
|
-
target.click();
|
|
703
|
-
return { success: true, clicked: target.tagName + (target.className ? "." + target.className.split(" ")[0] : ""), found: elements.length };
|
|
704
|
-
})()
|
|
705
|
-
`;
|
|
816
|
+
clickCode = generateClickCode({ type: 'selector', selector: selector }, index);
|
|
706
817
|
}
|
|
707
818
|
console.log(`[Sweetlink] Clicking: ${description}`);
|
|
708
819
|
// Debug: log the generated code
|
|
@@ -836,7 +947,12 @@ async function ruler(options) {
|
|
|
836
947
|
console.log(`\n[Sweetlink Ruler] ✓ Screenshot with overlay: ${result.screenshotPath}`);
|
|
837
948
|
}
|
|
838
949
|
}
|
|
839
|
-
return {
|
|
950
|
+
return {
|
|
951
|
+
summary: result.summary,
|
|
952
|
+
alignment: result.alignment,
|
|
953
|
+
results: result.results,
|
|
954
|
+
screenshotPath: result.screenshotPath,
|
|
955
|
+
};
|
|
840
956
|
}
|
|
841
957
|
catch (error) {
|
|
842
958
|
console.error('[Sweetlink] Error:', error instanceof Error ? error.message : error);
|
|
@@ -977,19 +1093,21 @@ async function findLsofPath() {
|
|
|
977
1093
|
* Find and kill process using a specific port (fallback method)
|
|
978
1094
|
*/
|
|
979
1095
|
async function killProcessOnPort(port) {
|
|
980
|
-
const {
|
|
1096
|
+
const { execFile } = await import('child_process');
|
|
981
1097
|
const { promisify } = await import('util');
|
|
982
|
-
const
|
|
1098
|
+
const execFileAsync = promisify(execFile);
|
|
983
1099
|
const lsofPath = await findLsofPath();
|
|
984
1100
|
try {
|
|
985
|
-
const { stdout } = await
|
|
1101
|
+
const { stdout } = await execFileAsync(lsofPath, ['-ti', `:${port}`]);
|
|
986
1102
|
const pids = stdout.trim().split('\n').filter(Boolean);
|
|
987
1103
|
if (pids.length === 0) {
|
|
988
1104
|
return false;
|
|
989
1105
|
}
|
|
990
1106
|
for (const pid of pids) {
|
|
1107
|
+
if (!/^\d+$/.test(pid))
|
|
1108
|
+
continue;
|
|
991
1109
|
try {
|
|
992
|
-
await
|
|
1110
|
+
await execFileAsync('/bin/kill', ['-9', pid]);
|
|
993
1111
|
console.log(` Killed process ${pid} on port ${port}`);
|
|
994
1112
|
}
|
|
995
1113
|
catch {
|
|
@@ -1265,59 +1383,65 @@ async function getVitals(options) {
|
|
|
1265
1383
|
process.exit(1);
|
|
1266
1384
|
}
|
|
1267
1385
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
Usage:
|
|
1273
|
-
pnpm sweetlink <command> [options]
|
|
1274
|
-
|
|
1275
|
-
Commands:
|
|
1276
|
-
|
|
1277
|
-
screenshot [options]
|
|
1386
|
+
// Per-command help text, keyed by canonical command name
|
|
1387
|
+
const COMMAND_HELP = {
|
|
1388
|
+
screenshot: ` screenshot [options]
|
|
1278
1389
|
Take a screenshot of the current page or element
|
|
1279
1390
|
|
|
1280
|
-
TWO-TIER STRATEGY
|
|
1281
|
-
Tier 1 (Default): html2canvas WebSocket
|
|
1282
|
-
|
|
1391
|
+
TWO-TIER STRATEGY:
|
|
1392
|
+
Tier 1 (Default): html2canvas via WebSocket
|
|
1393
|
+
- Captures viewport at 0.5x scale (PNG, typically 50-300KB)
|
|
1394
|
+
- Zero setup, works with any devbar-enabled page
|
|
1395
|
+
- Use --full-page to capture the entire scrollable page (larger files)
|
|
1396
|
+
|
|
1397
|
+
Tier 2 (Escalation): Playwright/CDP
|
|
1398
|
+
- Pixel-perfect native Chrome rendering
|
|
1399
|
+
- Respects --viewport/--width/--height for custom dimensions
|
|
1400
|
+
- Use --force-cdp to force this method, or it auto-activates for --hover
|
|
1401
|
+
- Auto-launches headless browser if no browser connected
|
|
1283
1402
|
|
|
1284
1403
|
Options:
|
|
1285
|
-
--url <url>
|
|
1404
|
+
--url <url> Navigate browser to URL before capturing (default: http://localhost:3000)
|
|
1405
|
+
Navigates connected browser via WS; if none connected, opens one via Playwright
|
|
1286
1406
|
--selector <css-selector> CSS selector of element to screenshot
|
|
1287
1407
|
--output <path> Output file path (default: screenshot-<timestamp>.png)
|
|
1288
|
-
--full-page Capture full page (
|
|
1289
|
-
--width <pixels> Viewport width (e.g., 768 for tablet, 375 for mobile)
|
|
1290
|
-
--height <pixels> Viewport height (default: width * 1.5)
|
|
1291
|
-
--viewport <preset|WxH> Viewport preset (mobile, tablet, desktop) or WIDTHxHEIGHT
|
|
1292
|
-
--force-cdp Force CDP method
|
|
1293
|
-
--force-ws Force WebSocket method (default)
|
|
1408
|
+
--full-page Capture full scrollable page (default: viewport only)
|
|
1409
|
+
--width <pixels> Viewport width for Playwright (e.g., 768 for tablet, 375 for mobile)
|
|
1410
|
+
--height <pixels> Viewport height for Playwright (default: width * 1.5)
|
|
1411
|
+
--viewport <preset|WxH> Viewport preset for Playwright (mobile, tablet, desktop) or WIDTHxHEIGHT
|
|
1412
|
+
--force-cdp Force Playwright/CDP method
|
|
1413
|
+
--force-ws Force WebSocket/html2canvas method (default)
|
|
1294
1414
|
--no-wait Skip server readiness check (use if server is already running)
|
|
1295
1415
|
--wait-timeout <ms> Max time to wait for server (default: 30000ms)
|
|
1296
1416
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
pnpm sweetlink screenshot --width 768 # Tablet viewport (768x1152)
|
|
1302
|
-
pnpm sweetlink screenshot --width 375 --height 667 # iPhone SE viewport
|
|
1303
|
-
pnpm sweetlink screenshot --viewport tablet # Preset: 768x1024
|
|
1304
|
-
pnpm sweetlink screenshot --force-cdp --full-page # Tier 2 (escalation)
|
|
1417
|
+
Size comparison:
|
|
1418
|
+
Tier 1 (WS, viewport): ~50-300KB PNG at 0.5x scale
|
|
1419
|
+
Tier 1 (WS, --full-page): ~1-5MB PNG (entire page)
|
|
1420
|
+
Tier 2 (Playwright): ~200-800KB PNG at native resolution
|
|
1305
1421
|
|
|
1306
|
-
|
|
1422
|
+
Examples:
|
|
1423
|
+
pnpm sweetlink screenshot # Viewport screenshot (small)
|
|
1424
|
+
pnpm sweetlink screenshot --url "http://localhost:3000/company/foo" # Navigate then capture
|
|
1425
|
+
pnpm sweetlink screenshot --selector ".company-card" # Element screenshot
|
|
1426
|
+
pnpm sweetlink screenshot --full-page # Full scrollable page
|
|
1427
|
+
pnpm sweetlink screenshot --force-cdp --viewport tablet # Playwright at 768x1024
|
|
1428
|
+
pnpm sweetlink screenshot --force-cdp --width 375 --height 667 # Playwright at iPhone SE`,
|
|
1429
|
+
query: ` query --selector <css-selector> [options]
|
|
1307
1430
|
Query DOM elements and return data
|
|
1308
1431
|
|
|
1309
1432
|
Options:
|
|
1310
1433
|
--selector <css-selector> CSS selector to query (required)
|
|
1311
1434
|
--property <name> Property to get from elements
|
|
1435
|
+
--url <url> Navigate browser to URL before querying
|
|
1312
1436
|
--wait-for <css-selector> Wait for selector to exist before querying (handles hydration)
|
|
1313
1437
|
--wait-timeout <ms> Max wait time for --wait-for (default: 10000ms)
|
|
1314
1438
|
|
|
1315
1439
|
Examples:
|
|
1316
1440
|
pnpm sweetlink query --selector "h1"
|
|
1317
1441
|
pnpm sweetlink query --selector ".card" --property "offsetWidth"
|
|
1318
|
-
pnpm sweetlink query --selector "
|
|
1319
|
-
|
|
1320
|
-
logs [options]
|
|
1442
|
+
pnpm sweetlink query --selector "h1" --url "http://localhost:3000/about"
|
|
1443
|
+
pnpm sweetlink query --selector "img" --wait-for "img[src*='hero']"`,
|
|
1444
|
+
logs: ` logs [options]
|
|
1321
1445
|
Get console logs from the browser
|
|
1322
1446
|
|
|
1323
1447
|
Options:
|
|
@@ -1338,9 +1462,8 @@ Commands:
|
|
|
1338
1462
|
pnpm sweetlink logs --dedupe # Remove duplicates
|
|
1339
1463
|
pnpm sweetlink logs --format json # Full JSON output
|
|
1340
1464
|
pnpm sweetlink logs --format summary # LLM-optimized summary
|
|
1341
|
-
pnpm sweetlink logs --format json --dedupe # JSON with deduplication
|
|
1342
|
-
|
|
1343
|
-
exec --code <javascript>
|
|
1465
|
+
pnpm sweetlink logs --format json --dedupe # JSON with deduplication`,
|
|
1466
|
+
exec: ` exec --code <javascript>
|
|
1344
1467
|
Execute JavaScript in the browser context
|
|
1345
1468
|
|
|
1346
1469
|
Code is evaluated as an expression. Bare \`return\` statements are auto-wrapped in an IIFE.
|
|
@@ -1348,17 +1471,18 @@ Commands:
|
|
|
1348
1471
|
|
|
1349
1472
|
Options:
|
|
1350
1473
|
--code <javascript> JavaScript code to execute (required)
|
|
1474
|
+
--url <url> Navigate browser to URL before executing
|
|
1351
1475
|
--wait-for <css-selector> Wait for selector to exist before executing (handles hydration)
|
|
1352
1476
|
--wait-timeout <ms> Max wait time for --wait-for (default: 10000ms)
|
|
1353
1477
|
|
|
1354
1478
|
Examples:
|
|
1355
1479
|
pnpm sweetlink exec --code "document.title"
|
|
1356
1480
|
pnpm sweetlink exec --code "document.querySelectorAll('.card').length"
|
|
1481
|
+
pnpm sweetlink exec --code "document.title" --url "http://localhost:3000/about"
|
|
1357
1482
|
pnpm sweetlink exec --code "const x = 1 + 2; return x;"
|
|
1358
1483
|
pnpm sweetlink exec --code "fetch('/api/health').then(r => r.status)"
|
|
1359
|
-
pnpm sweetlink exec --code "document.querySelectorAll('img').length" --wait-for "img[src*='hero']"
|
|
1360
|
-
|
|
1361
|
-
click [options]
|
|
1484
|
+
pnpm sweetlink exec --code "document.querySelectorAll('img').length" --wait-for "img[src*='hero']"`,
|
|
1485
|
+
click: ` click [options]
|
|
1362
1486
|
Click an element in the browser
|
|
1363
1487
|
|
|
1364
1488
|
Options:
|
|
@@ -1373,9 +1497,8 @@ Commands:
|
|
|
1373
1497
|
pnpm sweetlink click --selector "button.submit"
|
|
1374
1498
|
pnpm sweetlink click --text "Submit"
|
|
1375
1499
|
pnpm sweetlink click --selector "th" --text "Rank"
|
|
1376
|
-
pnpm sweetlink click --selector ".tab" --index 2
|
|
1377
|
-
|
|
1378
|
-
network [options] (requires CDP)
|
|
1500
|
+
pnpm sweetlink click --selector ".tab" --index 2`,
|
|
1501
|
+
network: ` network [options] (requires CDP)
|
|
1379
1502
|
Get network requests from the browser
|
|
1380
1503
|
|
|
1381
1504
|
Options:
|
|
@@ -1383,9 +1506,8 @@ Commands:
|
|
|
1383
1506
|
|
|
1384
1507
|
Examples:
|
|
1385
1508
|
pnpm sweetlink network
|
|
1386
|
-
pnpm sweetlink network --filter "/api/"
|
|
1387
|
-
|
|
1388
|
-
refresh [options]
|
|
1509
|
+
pnpm sweetlink network --filter "/api/"`,
|
|
1510
|
+
refresh: ` refresh [options]
|
|
1389
1511
|
Refresh the browser page
|
|
1390
1512
|
|
|
1391
1513
|
Options:
|
|
@@ -1393,9 +1515,8 @@ Commands:
|
|
|
1393
1515
|
|
|
1394
1516
|
Examples:
|
|
1395
1517
|
pnpm sweetlink refresh
|
|
1396
|
-
pnpm sweetlink refresh --hard
|
|
1397
|
-
|
|
1398
|
-
schema [options]
|
|
1518
|
+
pnpm sweetlink refresh --hard`,
|
|
1519
|
+
schema: ` schema [options]
|
|
1399
1520
|
Extract page schema (JSON-LD, Open Graph, Twitter, meta tags, microdata)
|
|
1400
1521
|
|
|
1401
1522
|
Options:
|
|
@@ -1405,9 +1526,8 @@ Commands:
|
|
|
1405
1526
|
Examples:
|
|
1406
1527
|
pnpm sweetlink schema
|
|
1407
1528
|
pnpm sweetlink schema --format json
|
|
1408
|
-
pnpm sweetlink schema --output .tmp/schema.json --format json
|
|
1409
|
-
|
|
1410
|
-
outline [options]
|
|
1529
|
+
pnpm sweetlink schema --output .tmp/schema.json --format json`,
|
|
1530
|
+
outline: ` outline [options]
|
|
1411
1531
|
Extract document outline (headings, sections, landmarks)
|
|
1412
1532
|
|
|
1413
1533
|
Options:
|
|
@@ -1417,9 +1537,8 @@ Commands:
|
|
|
1417
1537
|
Examples:
|
|
1418
1538
|
pnpm sweetlink outline
|
|
1419
1539
|
pnpm sweetlink outline --format json
|
|
1420
|
-
pnpm sweetlink outline --output .tmp/outline.md
|
|
1421
|
-
|
|
1422
|
-
a11y [options]
|
|
1540
|
+
pnpm sweetlink outline --output .tmp/outline.md`,
|
|
1541
|
+
a11y: ` a11y [options]
|
|
1423
1542
|
Run accessibility audit (requires axe-core via devbar)
|
|
1424
1543
|
|
|
1425
1544
|
Options:
|
|
@@ -1429,9 +1548,8 @@ Commands:
|
|
|
1429
1548
|
Examples:
|
|
1430
1549
|
pnpm sweetlink a11y
|
|
1431
1550
|
pnpm sweetlink a11y --format json
|
|
1432
|
-
pnpm sweetlink a11y --output .tmp/a11y-report.json --format json
|
|
1433
|
-
|
|
1434
|
-
vitals [options]
|
|
1551
|
+
pnpm sweetlink a11y --output .tmp/a11y-report.json --format json`,
|
|
1552
|
+
vitals: ` vitals [options]
|
|
1435
1553
|
Collect Core Web Vitals (FCP, LCP, CLS, INP, page size)
|
|
1436
1554
|
|
|
1437
1555
|
Options:
|
|
@@ -1439,9 +1557,8 @@ Commands:
|
|
|
1439
1557
|
|
|
1440
1558
|
Examples:
|
|
1441
1559
|
pnpm sweetlink vitals
|
|
1442
|
-
pnpm sweetlink vitals --format json
|
|
1443
|
-
|
|
1444
|
-
ruler [options]
|
|
1560
|
+
pnpm sweetlink vitals --format json`,
|
|
1561
|
+
ruler: ` ruler [options]
|
|
1445
1562
|
Measure elements and inject visual overlay for alignment verification.
|
|
1446
1563
|
Shows bounding boxes, center lines, dimensions, and alignment offsets.
|
|
1447
1564
|
|
|
@@ -1466,9 +1583,8 @@ Commands:
|
|
|
1466
1583
|
pnpm sweetlink ruler --selector "article h2" --selector "article header > div:first-child"
|
|
1467
1584
|
pnpm sweetlink ruler --preset card-header --output .tmp/ruler.png
|
|
1468
1585
|
pnpm sweetlink ruler --preset card-header --format json
|
|
1469
|
-
pnpm sweetlink ruler --selector ".logo" --selector ".nav-item" --show-position
|
|
1470
|
-
|
|
1471
|
-
wait [options]
|
|
1586
|
+
pnpm sweetlink ruler --selector ".logo" --selector ".nav-item" --show-position`,
|
|
1587
|
+
wait: ` wait [options]
|
|
1472
1588
|
Wait for server to be ready (blocks until available or timeout)
|
|
1473
1589
|
Eliminates need for external sleep commands in scripts.
|
|
1474
1590
|
|
|
@@ -1479,9 +1595,8 @@ Commands:
|
|
|
1479
1595
|
Examples:
|
|
1480
1596
|
pnpm sweetlink wait
|
|
1481
1597
|
pnpm sweetlink wait --url "http://localhost:3000"
|
|
1482
|
-
pnpm sweetlink wait --timeout 60000
|
|
1483
|
-
|
|
1484
|
-
status [options]
|
|
1598
|
+
pnpm sweetlink wait --timeout 60000`,
|
|
1599
|
+
status: ` status [options]
|
|
1485
1600
|
Quick server status check (non-blocking, instant)
|
|
1486
1601
|
|
|
1487
1602
|
Options:
|
|
@@ -1489,14 +1604,14 @@ Commands:
|
|
|
1489
1604
|
|
|
1490
1605
|
Examples:
|
|
1491
1606
|
pnpm sweetlink status
|
|
1492
|
-
pnpm sweetlink status --url "http://localhost:8080"
|
|
1493
|
-
|
|
1494
|
-
cleanup [options]
|
|
1607
|
+
pnpm sweetlink status --url "http://localhost:8080"`,
|
|
1608
|
+
cleanup: ` cleanup [options]
|
|
1495
1609
|
Find and close stale Sweetlink servers that weren't properly shut down.
|
|
1496
1610
|
Useful when ports are stuck after crashes or forced process kills.
|
|
1497
1611
|
|
|
1498
1612
|
Options:
|
|
1499
1613
|
--force Force kill processes if graceful shutdown fails
|
|
1614
|
+
--verbose Show detailed scan progress
|
|
1500
1615
|
|
|
1501
1616
|
What it does:
|
|
1502
1617
|
1. Scans common Sweetlink port ranges (9223-9233, 11396-11406, etc.)
|
|
@@ -1506,8 +1621,23 @@ Commands:
|
|
|
1506
1621
|
|
|
1507
1622
|
Examples:
|
|
1508
1623
|
pnpm sweetlink cleanup # Graceful shutdown
|
|
1509
|
-
pnpm sweetlink cleanup --force # Force kill if needed
|
|
1624
|
+
pnpm sweetlink cleanup --force # Force kill if needed`,
|
|
1625
|
+
setup: ` setup
|
|
1626
|
+
Install Claude Code integration (screenshot skill and context files).
|
|
1627
|
+
Creates symlinks in your .claude/ directory so Claude can use the /screenshot
|
|
1628
|
+
skill and sweetlink agent guide automatically.
|
|
1629
|
+
|
|
1630
|
+
Re-run after upgrading sweetlink to pick up any skill updates.
|
|
1510
1631
|
|
|
1632
|
+
Examples:
|
|
1633
|
+
pnpm sweetlink setup`,
|
|
1634
|
+
};
|
|
1635
|
+
// Aliases that map to canonical command names
|
|
1636
|
+
const COMMAND_ALIASES = {
|
|
1637
|
+
measure: 'ruler',
|
|
1638
|
+
accessibility: 'a11y',
|
|
1639
|
+
};
|
|
1640
|
+
const GLOBAL_HELP = `
|
|
1511
1641
|
Global Flags:
|
|
1512
1642
|
--json Output structured JSON (envelope with ok, command, data, duration)
|
|
1513
1643
|
--output-schema Print TypeScript types for --json output, then exit
|
|
@@ -1528,15 +1658,38 @@ Environment Variables:
|
|
|
1528
1658
|
CHROME_CDP_PORT Chrome DevTools Protocol port (default: 9222)
|
|
1529
1659
|
|
|
1530
1660
|
Documentation:
|
|
1531
|
-
Agent
|
|
1532
|
-
|
|
1533
|
-
|
|
1661
|
+
Agent Guide: .claude/context/sweetlink-agent-guide.md`;
|
|
1662
|
+
function showHelp() {
|
|
1663
|
+
console.log(`
|
|
1664
|
+
Sweetlink CLI - Autonomous Development Bridge
|
|
1665
|
+
|
|
1666
|
+
Usage:
|
|
1667
|
+
pnpm sweetlink <command> [options]
|
|
1668
|
+
|
|
1669
|
+
Commands:
|
|
1534
1670
|
`);
|
|
1671
|
+
for (const help of Object.values(COMMAND_HELP)) {
|
|
1672
|
+
console.log(help);
|
|
1673
|
+
console.log('');
|
|
1674
|
+
}
|
|
1675
|
+
console.log(GLOBAL_HELP);
|
|
1676
|
+
}
|
|
1677
|
+
function showCommandHelp(command) {
|
|
1678
|
+
const canonical = COMMAND_ALIASES[command] || command;
|
|
1679
|
+
const help = COMMAND_HELP[canonical];
|
|
1680
|
+
if (!help) {
|
|
1681
|
+
console.error(`[Sweetlink] Unknown command: ${command}`);
|
|
1682
|
+
console.error(`Run "pnpm sweetlink --help" to see all available commands.`);
|
|
1683
|
+
process.exit(1);
|
|
1684
|
+
}
|
|
1685
|
+
console.log(`\nSweetlink CLI - ${canonical}\n`);
|
|
1686
|
+
console.log(help);
|
|
1687
|
+
console.log(GLOBAL_HELP);
|
|
1535
1688
|
}
|
|
1536
1689
|
// CLI argument parsing
|
|
1537
1690
|
const args = process.argv.slice(2);
|
|
1538
1691
|
// Skip global flags to find the actual command
|
|
1539
|
-
const commandType = args.find(a => !a.startsWith('--')) || args[0];
|
|
1692
|
+
const commandType = args.find((a) => !a.startsWith('--')) || args[0];
|
|
1540
1693
|
if (!commandType || commandType === '--help' || commandType === '-h') {
|
|
1541
1694
|
showHelp();
|
|
1542
1695
|
process.exit(0);
|
|
@@ -1549,42 +1702,126 @@ function getArg(flag) {
|
|
|
1549
1702
|
function hasFlag(flag) {
|
|
1550
1703
|
return args.includes(flag);
|
|
1551
1704
|
}
|
|
1705
|
+
// Per-command --help: `pnpm sweetlink screenshot --help`
|
|
1706
|
+
if (hasFlag('--help') || hasFlag('-h')) {
|
|
1707
|
+
showCommandHelp(commandType);
|
|
1708
|
+
process.exit(0);
|
|
1709
|
+
}
|
|
1552
1710
|
// Handle --output-schema before main switch
|
|
1553
1711
|
if (hasFlag('--output-schema')) {
|
|
1554
1712
|
// If commandType is a known command, print just that schema; otherwise print all
|
|
1555
|
-
const knownCommands = [
|
|
1713
|
+
const knownCommands = [
|
|
1714
|
+
'screenshot',
|
|
1715
|
+
'query',
|
|
1716
|
+
'logs',
|
|
1717
|
+
'exec',
|
|
1718
|
+
'click',
|
|
1719
|
+
'refresh',
|
|
1720
|
+
'ruler',
|
|
1721
|
+
'measure',
|
|
1722
|
+
'network',
|
|
1723
|
+
'schema',
|
|
1724
|
+
'outline',
|
|
1725
|
+
'a11y',
|
|
1726
|
+
'accessibility',
|
|
1727
|
+
'vitals',
|
|
1728
|
+
'cleanup',
|
|
1729
|
+
'wait',
|
|
1730
|
+
'status',
|
|
1731
|
+
];
|
|
1556
1732
|
const schemaCommand = knownCommands.includes(commandType)
|
|
1557
|
-
?
|
|
1733
|
+
? commandType === 'measure'
|
|
1734
|
+
? 'ruler'
|
|
1735
|
+
: commandType === 'accessibility'
|
|
1736
|
+
? 'a11y'
|
|
1737
|
+
: commandType
|
|
1558
1738
|
: undefined;
|
|
1559
1739
|
printOutputSchema(schemaCommand);
|
|
1560
1740
|
process.exit(0);
|
|
1561
1741
|
}
|
|
1562
1742
|
const jsonMode = hasFlag('--json');
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
}
|
|
1570
|
-
// Capture the last console.error message for error envelopes
|
|
1743
|
+
/**
|
|
1744
|
+
* Set up JSON mode: suppress console.log/warn, capture errors, and intercept process.exit
|
|
1745
|
+
* to emit structured JSON envelopes on failure.
|
|
1746
|
+
*/
|
|
1747
|
+
function setupJsonMode(command, startTime) {
|
|
1748
|
+
console.log = () => { };
|
|
1749
|
+
console.warn = () => { };
|
|
1571
1750
|
let lastErrorMsg = '';
|
|
1572
1751
|
const origError = console.error;
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
};
|
|
1578
|
-
}
|
|
1579
|
-
// Intercept process.exit in json mode to emit error envelope
|
|
1752
|
+
console.error = (...errorArgs) => {
|
|
1753
|
+
lastErrorMsg = errorArgs.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
|
1754
|
+
origError(...errorArgs);
|
|
1755
|
+
};
|
|
1580
1756
|
const origExit = process.exit;
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1757
|
+
process.exit = ((code) => {
|
|
1758
|
+
if (code && code !== 0) {
|
|
1759
|
+
emitJson({
|
|
1760
|
+
ok: false,
|
|
1761
|
+
command,
|
|
1762
|
+
data: null,
|
|
1763
|
+
error: lastErrorMsg || `Process exited with code ${code}`,
|
|
1764
|
+
duration: Date.now() - startTime,
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
origExit(code);
|
|
1768
|
+
});
|
|
1769
|
+
return { origExit, getLastError: () => lastErrorMsg };
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Handle the `wait` command: wait for a server to be ready.
|
|
1773
|
+
*/
|
|
1774
|
+
async function handleWaitCommand() {
|
|
1775
|
+
const waitUrl = getArg('--url') || 'http://localhost:3000';
|
|
1776
|
+
const waitTimeout = getArg('--timeout')
|
|
1777
|
+
? parseInt(getArg('--timeout'), 10)
|
|
1778
|
+
: SERVER_READY_TIMEOUT;
|
|
1779
|
+
const waitStart = Date.now();
|
|
1780
|
+
try {
|
|
1781
|
+
await waitForServer(waitUrl, waitTimeout);
|
|
1782
|
+
console.log('[Sweetlink] ✓ Server is ready');
|
|
1783
|
+
return { url: waitUrl, ready: true, elapsed: Date.now() - waitStart };
|
|
1784
|
+
}
|
|
1785
|
+
catch (error) {
|
|
1786
|
+
console.error('[Sweetlink] ✗ Server not available:', error instanceof Error ? error.message : error);
|
|
1787
|
+
process.exit(1);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Handle the `status` command: quick non-blocking server health check.
|
|
1792
|
+
*/
|
|
1793
|
+
async function handleStatusCommand() {
|
|
1794
|
+
const statusUrl = getArg('--url') || 'http://localhost:3000';
|
|
1795
|
+
try {
|
|
1796
|
+
const parsedUrl = new URL(statusUrl);
|
|
1797
|
+
const healthCheckUrl = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
|
1798
|
+
const controller = new AbortController();
|
|
1799
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
1800
|
+
const response = await fetch(healthCheckUrl, {
|
|
1801
|
+
method: 'HEAD',
|
|
1802
|
+
signal: controller.signal,
|
|
1587
1803
|
});
|
|
1804
|
+
clearTimeout(timeoutId);
|
|
1805
|
+
if (response.ok || response.status === 304) {
|
|
1806
|
+
console.log(`[Sweetlink] ✓ Server at ${healthCheckUrl} is running`);
|
|
1807
|
+
return { url: statusUrl, running: true, statusCode: response.status };
|
|
1808
|
+
}
|
|
1809
|
+
else {
|
|
1810
|
+
console.log(`[Sweetlink] ⚠ Server responded with status ${response.status}`);
|
|
1811
|
+
process.exit(1);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
catch {
|
|
1815
|
+
console.log(`[Sweetlink] ✗ Server at ${statusUrl} is not responding`);
|
|
1816
|
+
process.exit(1);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
(async () => {
|
|
1820
|
+
const startTime = Date.now();
|
|
1821
|
+
let origExit = process.exit;
|
|
1822
|
+
if (jsonMode) {
|
|
1823
|
+
const jsonSetup = setupJsonMode(commandType, startTime);
|
|
1824
|
+
origExit = jsonSetup.origExit;
|
|
1588
1825
|
}
|
|
1589
1826
|
try {
|
|
1590
1827
|
let result;
|
|
@@ -1614,14 +1851,20 @@ const jsonMode = hasFlag('--json');
|
|
|
1614
1851
|
console.error('[Sweetlink] Error: --selector is required for query command');
|
|
1615
1852
|
process.exit(1);
|
|
1616
1853
|
}
|
|
1617
|
-
if (
|
|
1618
|
-
|
|
1854
|
+
if (getArg('--url')) {
|
|
1855
|
+
const navigated = await navigateBrowser(getArg('--url'));
|
|
1856
|
+
if (!navigated) {
|
|
1857
|
+
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
1858
|
+
process.exit(1);
|
|
1859
|
+
}
|
|
1619
1860
|
}
|
|
1620
1861
|
result = await queryDOM({
|
|
1621
1862
|
selector,
|
|
1622
1863
|
property: getArg('--property'),
|
|
1623
1864
|
waitFor: getArg('--wait-for'),
|
|
1624
|
-
waitTimeout: getArg('--wait-timeout')
|
|
1865
|
+
waitTimeout: getArg('--wait-timeout')
|
|
1866
|
+
? parseInt(getArg('--wait-timeout'), 10)
|
|
1867
|
+
: undefined,
|
|
1625
1868
|
});
|
|
1626
1869
|
break;
|
|
1627
1870
|
}
|
|
@@ -1641,13 +1884,19 @@ const jsonMode = hasFlag('--json');
|
|
|
1641
1884
|
console.error('[Sweetlink] Error: --code is required for exec command');
|
|
1642
1885
|
process.exit(1);
|
|
1643
1886
|
}
|
|
1644
|
-
if (
|
|
1645
|
-
|
|
1887
|
+
if (getArg('--url')) {
|
|
1888
|
+
const navigated = await navigateBrowser(getArg('--url'));
|
|
1889
|
+
if (!navigated) {
|
|
1890
|
+
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
1891
|
+
process.exit(1);
|
|
1892
|
+
}
|
|
1646
1893
|
}
|
|
1647
1894
|
result = await execJS({
|
|
1648
1895
|
code,
|
|
1649
1896
|
waitFor: getArg('--wait-for'),
|
|
1650
|
-
waitTimeout: getArg('--wait-timeout')
|
|
1897
|
+
waitTimeout: getArg('--wait-timeout')
|
|
1898
|
+
? parseInt(getArg('--wait-timeout'), 10)
|
|
1899
|
+
: undefined,
|
|
1651
1900
|
});
|
|
1652
1901
|
break;
|
|
1653
1902
|
}
|
|
@@ -1691,52 +1940,12 @@ const jsonMode = hasFlag('--json');
|
|
|
1691
1940
|
});
|
|
1692
1941
|
break;
|
|
1693
1942
|
}
|
|
1694
|
-
case 'wait':
|
|
1695
|
-
|
|
1696
|
-
const waitUrl = getArg('--url') || 'http://localhost:3000';
|
|
1697
|
-
const waitTimeout = getArg('--timeout')
|
|
1698
|
-
? parseInt(getArg('--timeout'), 10)
|
|
1699
|
-
: SERVER_READY_TIMEOUT;
|
|
1700
|
-
const waitStart = Date.now();
|
|
1701
|
-
try {
|
|
1702
|
-
await waitForServer(waitUrl, waitTimeout);
|
|
1703
|
-
console.log('[Sweetlink] ✓ Server is ready');
|
|
1704
|
-
result = { url: waitUrl, ready: true, elapsed: Date.now() - waitStart };
|
|
1705
|
-
}
|
|
1706
|
-
catch (error) {
|
|
1707
|
-
console.error('[Sweetlink] ✗ Server not available:', error instanceof Error ? error.message : error);
|
|
1708
|
-
process.exit(1);
|
|
1709
|
-
}
|
|
1943
|
+
case 'wait':
|
|
1944
|
+
result = await handleWaitCommand();
|
|
1710
1945
|
break;
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
// Quick server status check (non-blocking)
|
|
1714
|
-
const statusUrl = getArg('--url') || 'http://localhost:3000';
|
|
1715
|
-
try {
|
|
1716
|
-
const parsedUrl = new URL(statusUrl);
|
|
1717
|
-
const healthCheckUrl = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
|
1718
|
-
const controller = new AbortController();
|
|
1719
|
-
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
1720
|
-
const response = await fetch(healthCheckUrl, {
|
|
1721
|
-
method: 'HEAD',
|
|
1722
|
-
signal: controller.signal,
|
|
1723
|
-
});
|
|
1724
|
-
clearTimeout(timeoutId);
|
|
1725
|
-
if (response.ok || response.status === 304) {
|
|
1726
|
-
console.log(`[Sweetlink] ✓ Server at ${healthCheckUrl} is running`);
|
|
1727
|
-
result = { url: statusUrl, running: true, statusCode: response.status };
|
|
1728
|
-
}
|
|
1729
|
-
else {
|
|
1730
|
-
console.log(`[Sweetlink] ⚠ Server responded with status ${response.status}`);
|
|
1731
|
-
process.exit(1);
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
catch {
|
|
1735
|
-
console.log(`[Sweetlink] ✗ Server at ${statusUrl} is not responding`);
|
|
1736
|
-
process.exit(1);
|
|
1737
|
-
}
|
|
1946
|
+
case 'status':
|
|
1947
|
+
result = await handleStatusCommand();
|
|
1738
1948
|
break;
|
|
1739
|
-
}
|
|
1740
1949
|
case 'schema':
|
|
1741
1950
|
result = await getSchema({
|
|
1742
1951
|
format: getArg('--format'),
|
|
@@ -1767,6 +1976,14 @@ const jsonMode = hasFlag('--json');
|
|
|
1767
1976
|
verbose: hasFlag('--verbose'),
|
|
1768
1977
|
});
|
|
1769
1978
|
break;
|
|
1979
|
+
case 'setup': {
|
|
1980
|
+
// Run the setup script to symlink Claude context and skills
|
|
1981
|
+
const { execFileSync } = await import('child_process');
|
|
1982
|
+
const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
|
|
1983
|
+
const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
|
|
1984
|
+
execFileSync('node', [setupScript], { stdio: 'inherit' });
|
|
1985
|
+
break;
|
|
1986
|
+
}
|
|
1770
1987
|
default:
|
|
1771
1988
|
console.error(`[Sweetlink] Unknown command: ${commandType}`);
|
|
1772
1989
|
console.log('Run "pnpm sweetlink --help" for usage information');
|
|
@@ -1779,7 +1996,13 @@ const jsonMode = hasFlag('--json');
|
|
|
1779
1996
|
catch (error) {
|
|
1780
1997
|
if (jsonMode) {
|
|
1781
1998
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1782
|
-
emitJson({
|
|
1999
|
+
emitJson({
|
|
2000
|
+
ok: false,
|
|
2001
|
+
command: commandType,
|
|
2002
|
+
data: null,
|
|
2003
|
+
error: msg,
|
|
2004
|
+
duration: Date.now() - startTime,
|
|
2005
|
+
});
|
|
1783
2006
|
origExit(1);
|
|
1784
2007
|
}
|
|
1785
2008
|
console.error('[Sweetlink] Fatal error:', error);
|