@ytspar/sweetlink 1.10.0 → 1.11.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.
@@ -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 timeout = setTimeout(() => {
182
+ const timer = setTimeout(() => {
183
183
  ws.close();
184
184
  reject(new Error('Command timeout - is the dev server running?'));
185
- }, DEFAULT_TIMEOUT);
185
+ }, timeoutMs);
186
186
  ws.on('open', () => {
187
187
  ws.send(JSON.stringify(command));
188
188
  });
189
189
  ws.on('message', (data) => {
190
- clearTimeout(timeout);
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(timeout);
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,24 @@ 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 { path: getRelativePath(outputPath), width: result.width, height: result.height, method, selector: options.selector };
312
+ }
233
313
  async function screenshot(options) {
234
314
  // Convert --width/--height to viewport format if provided
235
315
  if (options.width && !options.viewport) {
@@ -264,26 +344,21 @@ async function screenshot(options) {
264
344
  // Check if CDP is available (unless force WS is specified)
265
345
  // Hover requires CDP/Playwright
266
346
  const requiresCDP = options.forceCDP || options.hover;
347
+ const playwrightOpts = {
348
+ selector: options.selector,
349
+ output: options.output,
350
+ fullPage: options.fullPage,
351
+ viewport: options.viewport,
352
+ hover: options.hover,
353
+ url: options.url,
354
+ };
267
355
  // If we need CDP/Playwright (for hover or force-cdp), or if CDP is available, use Playwright
268
356
  // Playwright will auto-launch if CDP is not available
269
357
  const shouldTryPlaywright = requiresCDP || (!options.forceWS && (await detectCDP()));
270
358
  if (shouldTryPlaywright) {
271
359
  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
360
  try {
276
- // Use Playwright (which handles CDP connection OR launches new browser)
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 };
361
+ return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (Auto-launch/CDP)');
287
362
  }
288
363
  catch (error) {
289
364
  if (options.forceCDP) {
@@ -295,6 +370,19 @@ async function screenshot(options) {
295
370
  }
296
371
  }
297
372
  // Fall back to WebSocket method
373
+ // Navigate the connected browser if --url is provided
374
+ if (options.url) {
375
+ const navigated = await navigateBrowser(options.url);
376
+ if (!navigated) {
377
+ if (options.forceWS) {
378
+ console.error('[Sweetlink] Could not navigate browser to', options.url);
379
+ process.exit(1);
380
+ }
381
+ // Auto-escalate to Playwright (opens browser, navigates, screenshots)
382
+ console.log('[Sweetlink] No browser for navigation — escalating to Playwright');
383
+ return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (auto-escalation)');
384
+ }
385
+ }
298
386
  console.log('[Sweetlink] Using WebSocket for screenshot');
299
387
  const command = {
300
388
  type: 'screenshot',
@@ -302,6 +390,7 @@ async function screenshot(options) {
302
390
  options: {
303
391
  fullPage: options.fullPage,
304
392
  a11y: options.a11y,
393
+ scale: 0.5,
305
394
  },
306
395
  };
307
396
  try {
@@ -311,19 +400,8 @@ async function screenshot(options) {
311
400
  // This happens after dev server restart when browser page hasn't been refreshed
312
401
  if (response.error?.includes('No browser client connected')) {
313
402
  console.log('[Sweetlink] No browser client - auto-escalating to Playwright');
314
- const outputPath = options.output || getDefaultScreenshotPath();
315
- ensureDir(outputPath);
316
403
  try {
317
- const result = await screenshotViaPlaywright({
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 };
404
+ return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (auto-escalation)');
327
405
  }
328
406
  catch (playwrightError) {
329
407
  console.error('[Sweetlink] Playwright fallback also failed:', playwrightError instanceof Error ? playwrightError.message : playwrightError);
@@ -441,6 +519,38 @@ function deduplicateLogs(logs) {
441
519
  return b.count - a.count;
442
520
  });
443
521
  }
522
+ /**
523
+ * Render console logs to stdout in human-readable text format with ANSI colors.
524
+ */
525
+ function renderLogsAsText(logs, dedupedLogs) {
526
+ const LEVEL_COLORS = {
527
+ error: '\x1b[31m',
528
+ warn: '\x1b[33m',
529
+ info: '\x1b[36m',
530
+ log: '\x1b[37m',
531
+ };
532
+ const reset = '\x1b[0m';
533
+ console.log(`[Sweetlink] ✓ Found ${logs.length} log entries${dedupedLogs ? ` (${dedupedLogs.length} unique)` : ''}`);
534
+ if (logs.length === 0) {
535
+ console.log(' No logs found');
536
+ return;
537
+ }
538
+ console.log('\nConsole Logs:');
539
+ if (dedupedLogs) {
540
+ dedupedLogs.forEach((log) => {
541
+ const levelColor = LEVEL_COLORS[log.level] || '\x1b[37m';
542
+ const countStr = log.count > 1 ? ` (×${log.count})` : '';
543
+ console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset}${countStr} - ${log.message}`);
544
+ });
545
+ }
546
+ else {
547
+ logs.forEach((log) => {
548
+ const levelColor = LEVEL_COLORS[log.level] || '\x1b[37m';
549
+ const time = new Date(log.timestamp).toLocaleTimeString();
550
+ console.log(` ${levelColor}[${log.level.toUpperCase()}]${reset} ${time} - ${log.message}`);
551
+ });
552
+ }
553
+ }
444
554
  async function getLogs(options) {
445
555
  if (options.format === 'text') {
446
556
  console.log('[Sweetlink] Getting console logs...');
@@ -504,39 +614,7 @@ async function getLogs(options) {
504
614
  }
505
615
  // Default text format
506
616
  const displayLogs = options.dedupe ? deduplicateLogs(logs) : null;
507
- console.log(`[Sweetlink] ✓ Found ${logs.length} log entries${options.dedupe ? ` (${displayLogs.length} unique)` : ''}`);
508
- if (logs.length > 0) {
509
- console.log('\nConsole Logs:');
510
- if (options.dedupe && displayLogs) {
511
- displayLogs.forEach((log) => {
512
- const levelColor = {
513
- error: '\x1b[31m',
514
- warn: '\x1b[33m',
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
- }
617
+ renderLogsAsText(logs, displayLogs);
540
618
  return { total: logs.length, format: 'text', deduped: !!options.dedupe, logs: displayLogs || logs, outputPath: undefined };
541
619
  }
542
620
  catch (error) {
@@ -653,28 +731,29 @@ async function execJS(options) {
653
731
  process.exit(1);
654
732
  }
655
733
  }
656
- async function click(options) {
657
- const { selector, text, index = 0 } = options;
658
- if (!selector && !text) {
659
- console.error('[Sweetlink] Error: Either --selector or --text is required');
660
- process.exit(1);
734
+ /**
735
+ * Generate JavaScript code that finds elements and clicks the one at the given index.
736
+ * Parameterized by the element-finding strategy: text content search or CSS selector.
737
+ */
738
+ function generateClickCode(strategy, index) {
739
+ // The element-finding expression differs, but the bounds-check + click + return is shared
740
+ const escapedSelector = JSON.stringify(strategy.selector);
741
+ let findExpression;
742
+ let notFoundMsg;
743
+ if (strategy.type === 'text') {
744
+ const escapedText = JSON.stringify(strategy.text);
745
+ findExpression = `Array.from(document.querySelectorAll(${escapedSelector})).filter(el => el.textContent?.includes(${escapedText}))`;
746
+ notFoundMsg = `"No element found with text: " + ${escapedText}`;
661
747
  }
662
- let clickCode;
663
- let description;
664
- if (text) {
665
- // Find element by text content
666
- const baseSelector = selector || '*';
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 = `
748
+ else {
749
+ findExpression = `Array.from(document.querySelectorAll(${escapedSelector}))`;
750
+ notFoundMsg = `"No element found matching: " + ${escapedSelector}`;
751
+ }
752
+ return `
672
753
  (() => {
673
- const searchText = ${escapedText};
674
- const elements = Array.from(document.querySelectorAll(${escapedSelector}))
675
- .filter(el => el.textContent?.includes(searchText));
754
+ const elements = ${findExpression};
676
755
  if (elements.length === 0) {
677
- return { success: false, error: "No element found with text: " + searchText };
756
+ return { success: false, error: ${notFoundMsg} };
678
757
  }
679
758
  const target = elements[${index}];
680
759
  if (!target) {
@@ -684,25 +763,23 @@ async function click(options) {
684
763
  return { success: true, clicked: target.tagName + (target.className ? "." + target.className.split(" ")[0] : ""), found: elements.length };
685
764
  })()
686
765
  `;
766
+ }
767
+ async function click(options) {
768
+ const { selector, text, index = 0 } = options;
769
+ if (!selector && !text) {
770
+ console.error('[Sweetlink] Error: Either --selector or --text is required');
771
+ process.exit(1);
772
+ }
773
+ let clickCode;
774
+ let description;
775
+ if (text) {
776
+ const baseSelector = selector || '*';
777
+ description = selector ? `"${text}" within ${selector}` : `"${text}"`;
778
+ clickCode = generateClickCode({ type: 'text', text, selector: baseSelector }, index);
687
779
  }
688
780
  else {
689
- // Find element by selector
690
- const escapedSelector = JSON.stringify(selector);
691
781
  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
- `;
782
+ clickCode = generateClickCode({ type: 'selector', selector: selector }, index);
706
783
  }
707
784
  console.log(`[Sweetlink] Clicking: ${description}`);
708
785
  // Debug: log the generated code
@@ -977,19 +1054,21 @@ async function findLsofPath() {
977
1054
  * Find and kill process using a specific port (fallback method)
978
1055
  */
979
1056
  async function killProcessOnPort(port) {
980
- const { exec } = await import('child_process');
1057
+ const { execFile } = await import('child_process');
981
1058
  const { promisify } = await import('util');
982
- const execAsync = promisify(exec);
1059
+ const execFileAsync = promisify(execFile);
983
1060
  const lsofPath = await findLsofPath();
984
1061
  try {
985
- const { stdout } = await execAsync(`${lsofPath} -ti :${port}`);
1062
+ const { stdout } = await execFileAsync(lsofPath, ['-ti', `:${port}`]);
986
1063
  const pids = stdout.trim().split('\n').filter(Boolean);
987
1064
  if (pids.length === 0) {
988
1065
  return false;
989
1066
  }
990
1067
  for (const pid of pids) {
1068
+ if (!/^\d+$/.test(pid))
1069
+ continue;
991
1070
  try {
992
- await execAsync(`/bin/kill -9 ${pid}`);
1071
+ await execFileAsync('/bin/kill', ['-9', pid]);
993
1072
  console.log(` Killed process ${pid} on port ${port}`);
994
1073
  }
995
1074
  catch {
@@ -1265,59 +1344,65 @@ async function getVitals(options) {
1265
1344
  process.exit(1);
1266
1345
  }
1267
1346
  }
1268
- function showHelp() {
1269
- console.log(`
1270
- Sweetlink CLI - Autonomous Development Bridge
1271
-
1272
- Usage:
1273
- pnpm sweetlink <command> [options]
1274
-
1275
- Commands:
1276
-
1277
- screenshot [options]
1347
+ // Per-command help text, keyed by canonical command name
1348
+ const COMMAND_HELP = {
1349
+ screenshot: ` screenshot [options]
1278
1350
  Take a screenshot of the current page or element
1279
1351
 
1280
- TWO-TIER STRATEGY (see .claude/context/sweetlink-screenshot-workflow.md):
1281
- Tier 1 (Default): html2canvas WebSocket - 131KB, zero setup, use 95% of time
1282
- Tier 2 (Escalation): CDP - 2.0MB native Chrome, use only to confirm discrepancies
1352
+ TWO-TIER STRATEGY:
1353
+ Tier 1 (Default): html2canvas via WebSocket
1354
+ - Captures viewport at 0.5x scale (PNG, typically 50-300KB)
1355
+ - Zero setup, works with any devbar-enabled page
1356
+ - Use --full-page to capture the entire scrollable page (larger files)
1357
+
1358
+ Tier 2 (Escalation): Playwright/CDP
1359
+ - Pixel-perfect native Chrome rendering
1360
+ - Respects --viewport/--width/--height for custom dimensions
1361
+ - Use --force-cdp to force this method, or it auto-activates for --hover
1362
+ - Auto-launches headless browser if no browser connected
1283
1363
 
1284
1364
  Options:
1285
- --url <url> Target URL to navigate to (default: http://localhost:3000)
1365
+ --url <url> Navigate browser to URL before capturing (default: http://localhost:3000)
1366
+ Navigates connected browser via WS; if none connected, opens one via Playwright
1286
1367
  --selector <css-selector> CSS selector of element to screenshot
1287
1368
  --output <path> Output file path (default: screenshot-<timestamp>.png)
1288
- --full-page Capture full page (not just viewport)
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 (requires Chrome debugging)
1293
- --force-ws Force WebSocket method (default)
1369
+ --full-page Capture full scrollable page (default: viewport only)
1370
+ --width <pixels> Viewport width for Playwright (e.g., 768 for tablet, 375 for mobile)
1371
+ --height <pixels> Viewport height for Playwright (default: width * 1.5)
1372
+ --viewport <preset|WxH> Viewport preset for Playwright (mobile, tablet, desktop) or WIDTHxHEIGHT
1373
+ --force-cdp Force Playwright/CDP method
1374
+ --force-ws Force WebSocket/html2canvas method (default)
1294
1375
  --no-wait Skip server readiness check (use if server is already running)
1295
1376
  --wait-timeout <ms> Max time to wait for server (default: 30000ms)
1296
1377
 
1297
- Examples:
1298
- pnpm sweetlink screenshot # Tier 1 (default)
1299
- pnpm sweetlink screenshot --url "http://localhost:3000/company/foo" # Navigate to specific page
1300
- pnpm sweetlink screenshot --selector ".company-card" # Tier 1 with selector
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)
1378
+ Size comparison:
1379
+ Tier 1 (WS, viewport): ~50-300KB PNG at 0.5x scale
1380
+ Tier 1 (WS, --full-page): ~1-5MB PNG (entire page)
1381
+ Tier 2 (Playwright): ~200-800KB PNG at native resolution
1305
1382
 
1306
- query --selector <css-selector> [options]
1383
+ Examples:
1384
+ pnpm sweetlink screenshot # Viewport screenshot (small)
1385
+ pnpm sweetlink screenshot --url "http://localhost:3000/company/foo" # Navigate then capture
1386
+ pnpm sweetlink screenshot --selector ".company-card" # Element screenshot
1387
+ pnpm sweetlink screenshot --full-page # Full scrollable page
1388
+ pnpm sweetlink screenshot --force-cdp --viewport tablet # Playwright at 768x1024
1389
+ pnpm sweetlink screenshot --force-cdp --width 375 --height 667 # Playwright at iPhone SE`,
1390
+ query: ` query --selector <css-selector> [options]
1307
1391
  Query DOM elements and return data
1308
1392
 
1309
1393
  Options:
1310
1394
  --selector <css-selector> CSS selector to query (required)
1311
1395
  --property <name> Property to get from elements
1396
+ --url <url> Navigate browser to URL before querying
1312
1397
  --wait-for <css-selector> Wait for selector to exist before querying (handles hydration)
1313
1398
  --wait-timeout <ms> Max wait time for --wait-for (default: 10000ms)
1314
1399
 
1315
1400
  Examples:
1316
1401
  pnpm sweetlink query --selector "h1"
1317
1402
  pnpm sweetlink query --selector ".card" --property "offsetWidth"
1318
- pnpm sweetlink query --selector "img" --wait-for "img[src*='hero']"
1319
-
1320
- logs [options]
1403
+ pnpm sweetlink query --selector "h1" --url "http://localhost:3000/about"
1404
+ pnpm sweetlink query --selector "img" --wait-for "img[src*='hero']"`,
1405
+ logs: ` logs [options]
1321
1406
  Get console logs from the browser
1322
1407
 
1323
1408
  Options:
@@ -1338,9 +1423,8 @@ Commands:
1338
1423
  pnpm sweetlink logs --dedupe # Remove duplicates
1339
1424
  pnpm sweetlink logs --format json # Full JSON output
1340
1425
  pnpm sweetlink logs --format summary # LLM-optimized summary
1341
- pnpm sweetlink logs --format json --dedupe # JSON with deduplication
1342
-
1343
- exec --code <javascript>
1426
+ pnpm sweetlink logs --format json --dedupe # JSON with deduplication`,
1427
+ exec: ` exec --code <javascript>
1344
1428
  Execute JavaScript in the browser context
1345
1429
 
1346
1430
  Code is evaluated as an expression. Bare \`return\` statements are auto-wrapped in an IIFE.
@@ -1348,17 +1432,18 @@ Commands:
1348
1432
 
1349
1433
  Options:
1350
1434
  --code <javascript> JavaScript code to execute (required)
1435
+ --url <url> Navigate browser to URL before executing
1351
1436
  --wait-for <css-selector> Wait for selector to exist before executing (handles hydration)
1352
1437
  --wait-timeout <ms> Max wait time for --wait-for (default: 10000ms)
1353
1438
 
1354
1439
  Examples:
1355
1440
  pnpm sweetlink exec --code "document.title"
1356
1441
  pnpm sweetlink exec --code "document.querySelectorAll('.card').length"
1442
+ pnpm sweetlink exec --code "document.title" --url "http://localhost:3000/about"
1357
1443
  pnpm sweetlink exec --code "const x = 1 + 2; return x;"
1358
1444
  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]
1445
+ pnpm sweetlink exec --code "document.querySelectorAll('img').length" --wait-for "img[src*='hero']"`,
1446
+ click: ` click [options]
1362
1447
  Click an element in the browser
1363
1448
 
1364
1449
  Options:
@@ -1373,9 +1458,8 @@ Commands:
1373
1458
  pnpm sweetlink click --selector "button.submit"
1374
1459
  pnpm sweetlink click --text "Submit"
1375
1460
  pnpm sweetlink click --selector "th" --text "Rank"
1376
- pnpm sweetlink click --selector ".tab" --index 2
1377
-
1378
- network [options] (requires CDP)
1461
+ pnpm sweetlink click --selector ".tab" --index 2`,
1462
+ network: ` network [options] (requires CDP)
1379
1463
  Get network requests from the browser
1380
1464
 
1381
1465
  Options:
@@ -1383,9 +1467,8 @@ Commands:
1383
1467
 
1384
1468
  Examples:
1385
1469
  pnpm sweetlink network
1386
- pnpm sweetlink network --filter "/api/"
1387
-
1388
- refresh [options]
1470
+ pnpm sweetlink network --filter "/api/"`,
1471
+ refresh: ` refresh [options]
1389
1472
  Refresh the browser page
1390
1473
 
1391
1474
  Options:
@@ -1393,9 +1476,8 @@ Commands:
1393
1476
 
1394
1477
  Examples:
1395
1478
  pnpm sweetlink refresh
1396
- pnpm sweetlink refresh --hard
1397
-
1398
- schema [options]
1479
+ pnpm sweetlink refresh --hard`,
1480
+ schema: ` schema [options]
1399
1481
  Extract page schema (JSON-LD, Open Graph, Twitter, meta tags, microdata)
1400
1482
 
1401
1483
  Options:
@@ -1405,9 +1487,8 @@ Commands:
1405
1487
  Examples:
1406
1488
  pnpm sweetlink schema
1407
1489
  pnpm sweetlink schema --format json
1408
- pnpm sweetlink schema --output .tmp/schema.json --format json
1409
-
1410
- outline [options]
1490
+ pnpm sweetlink schema --output .tmp/schema.json --format json`,
1491
+ outline: ` outline [options]
1411
1492
  Extract document outline (headings, sections, landmarks)
1412
1493
 
1413
1494
  Options:
@@ -1417,9 +1498,8 @@ Commands:
1417
1498
  Examples:
1418
1499
  pnpm sweetlink outline
1419
1500
  pnpm sweetlink outline --format json
1420
- pnpm sweetlink outline --output .tmp/outline.md
1421
-
1422
- a11y [options]
1501
+ pnpm sweetlink outline --output .tmp/outline.md`,
1502
+ a11y: ` a11y [options]
1423
1503
  Run accessibility audit (requires axe-core via devbar)
1424
1504
 
1425
1505
  Options:
@@ -1429,9 +1509,8 @@ Commands:
1429
1509
  Examples:
1430
1510
  pnpm sweetlink a11y
1431
1511
  pnpm sweetlink a11y --format json
1432
- pnpm sweetlink a11y --output .tmp/a11y-report.json --format json
1433
-
1434
- vitals [options]
1512
+ pnpm sweetlink a11y --output .tmp/a11y-report.json --format json`,
1513
+ vitals: ` vitals [options]
1435
1514
  Collect Core Web Vitals (FCP, LCP, CLS, INP, page size)
1436
1515
 
1437
1516
  Options:
@@ -1439,9 +1518,8 @@ Commands:
1439
1518
 
1440
1519
  Examples:
1441
1520
  pnpm sweetlink vitals
1442
- pnpm sweetlink vitals --format json
1443
-
1444
- ruler [options]
1521
+ pnpm sweetlink vitals --format json`,
1522
+ ruler: ` ruler [options]
1445
1523
  Measure elements and inject visual overlay for alignment verification.
1446
1524
  Shows bounding boxes, center lines, dimensions, and alignment offsets.
1447
1525
 
@@ -1466,9 +1544,8 @@ Commands:
1466
1544
  pnpm sweetlink ruler --selector "article h2" --selector "article header > div:first-child"
1467
1545
  pnpm sweetlink ruler --preset card-header --output .tmp/ruler.png
1468
1546
  pnpm sweetlink ruler --preset card-header --format json
1469
- pnpm sweetlink ruler --selector ".logo" --selector ".nav-item" --show-position
1470
-
1471
- wait [options]
1547
+ pnpm sweetlink ruler --selector ".logo" --selector ".nav-item" --show-position`,
1548
+ wait: ` wait [options]
1472
1549
  Wait for server to be ready (blocks until available or timeout)
1473
1550
  Eliminates need for external sleep commands in scripts.
1474
1551
 
@@ -1479,9 +1556,8 @@ Commands:
1479
1556
  Examples:
1480
1557
  pnpm sweetlink wait
1481
1558
  pnpm sweetlink wait --url "http://localhost:3000"
1482
- pnpm sweetlink wait --timeout 60000
1483
-
1484
- status [options]
1559
+ pnpm sweetlink wait --timeout 60000`,
1560
+ status: ` status [options]
1485
1561
  Quick server status check (non-blocking, instant)
1486
1562
 
1487
1563
  Options:
@@ -1489,14 +1565,14 @@ Commands:
1489
1565
 
1490
1566
  Examples:
1491
1567
  pnpm sweetlink status
1492
- pnpm sweetlink status --url "http://localhost:8080"
1493
-
1494
- cleanup [options]
1568
+ pnpm sweetlink status --url "http://localhost:8080"`,
1569
+ cleanup: ` cleanup [options]
1495
1570
  Find and close stale Sweetlink servers that weren't properly shut down.
1496
1571
  Useful when ports are stuck after crashes or forced process kills.
1497
1572
 
1498
1573
  Options:
1499
1574
  --force Force kill processes if graceful shutdown fails
1575
+ --verbose Show detailed scan progress
1500
1576
 
1501
1577
  What it does:
1502
1578
  1. Scans common Sweetlink port ranges (9223-9233, 11396-11406, etc.)
@@ -1506,8 +1582,23 @@ Commands:
1506
1582
 
1507
1583
  Examples:
1508
1584
  pnpm sweetlink cleanup # Graceful shutdown
1509
- pnpm sweetlink cleanup --force # Force kill if needed
1585
+ pnpm sweetlink cleanup --force # Force kill if needed`,
1586
+ setup: ` setup
1587
+ Install Claude Code integration (screenshot skill and context files).
1588
+ Creates symlinks in your .claude/ directory so Claude can use the /screenshot
1589
+ skill and sweetlink agent guide automatically.
1510
1590
 
1591
+ Re-run after upgrading sweetlink to pick up any skill updates.
1592
+
1593
+ Examples:
1594
+ pnpm sweetlink setup`,
1595
+ };
1596
+ // Aliases that map to canonical command names
1597
+ const COMMAND_ALIASES = {
1598
+ measure: 'ruler',
1599
+ accessibility: 'a11y',
1600
+ };
1601
+ const GLOBAL_HELP = `
1511
1602
  Global Flags:
1512
1603
  --json Output structured JSON (envelope with ok, command, data, duration)
1513
1604
  --output-schema Print TypeScript types for --json output, then exit
@@ -1528,10 +1619,33 @@ Environment Variables:
1528
1619
  CHROME_CDP_PORT Chrome DevTools Protocol port (default: 9222)
1529
1620
 
1530
1621
  Documentation:
1531
- Agent Workflow: .claude/context/sweetlink-screenshot-workflow.md
1532
- Agent Guide: .claude/context/sweetlink-agent-guide.md
1533
- CDP Guide: .claude/context/sweetlink-cdp-guide.md
1622
+ Agent Guide: .claude/context/sweetlink-agent-guide.md`;
1623
+ function showHelp() {
1624
+ console.log(`
1625
+ Sweetlink CLI - Autonomous Development Bridge
1626
+
1627
+ Usage:
1628
+ pnpm sweetlink <command> [options]
1629
+
1630
+ Commands:
1534
1631
  `);
1632
+ for (const help of Object.values(COMMAND_HELP)) {
1633
+ console.log(help);
1634
+ console.log('');
1635
+ }
1636
+ console.log(GLOBAL_HELP);
1637
+ }
1638
+ function showCommandHelp(command) {
1639
+ const canonical = COMMAND_ALIASES[command] || command;
1640
+ const help = COMMAND_HELP[canonical];
1641
+ if (!help) {
1642
+ console.error(`[Sweetlink] Unknown command: ${command}`);
1643
+ console.error(`Run "pnpm sweetlink --help" to see all available commands.`);
1644
+ process.exit(1);
1645
+ }
1646
+ console.log(`\nSweetlink CLI - ${canonical}\n`);
1647
+ console.log(help);
1648
+ console.log(GLOBAL_HELP);
1535
1649
  }
1536
1650
  // CLI argument parsing
1537
1651
  const args = process.argv.slice(2);
@@ -1549,6 +1663,11 @@ function getArg(flag) {
1549
1663
  function hasFlag(flag) {
1550
1664
  return args.includes(flag);
1551
1665
  }
1666
+ // Per-command --help: `pnpm sweetlink screenshot --help`
1667
+ if (hasFlag('--help') || hasFlag('-h')) {
1668
+ showCommandHelp(commandType);
1669
+ process.exit(0);
1670
+ }
1552
1671
  // Handle --output-schema before main switch
1553
1672
  if (hasFlag('--output-schema')) {
1554
1673
  // If commandType is a known command, print just that schema; otherwise print all
@@ -1560,31 +1679,82 @@ if (hasFlag('--output-schema')) {
1560
1679
  process.exit(0);
1561
1680
  }
1562
1681
  const jsonMode = hasFlag('--json');
1563
- (async () => {
1564
- const startTime = Date.now();
1565
- // In --json mode, suppress console.log/warn (preserve stderr for debugging)
1566
- if (jsonMode) {
1567
- console.log = () => { };
1568
- console.warn = () => { };
1569
- }
1570
- // Capture the last console.error message for error envelopes
1682
+ /**
1683
+ * Set up JSON mode: suppress console.log/warn, capture errors, and intercept process.exit
1684
+ * to emit structured JSON envelopes on failure.
1685
+ */
1686
+ function setupJsonMode(command, startTime) {
1687
+ console.log = () => { };
1688
+ console.warn = () => { };
1571
1689
  let lastErrorMsg = '';
1572
1690
  const origError = console.error;
1573
- if (jsonMode) {
1574
- console.error = (...errorArgs) => {
1575
- lastErrorMsg = errorArgs.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
1576
- origError(...errorArgs);
1577
- };
1578
- }
1579
- // Intercept process.exit in json mode to emit error envelope
1691
+ console.error = (...errorArgs) => {
1692
+ lastErrorMsg = errorArgs.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
1693
+ origError(...errorArgs);
1694
+ };
1580
1695
  const origExit = process.exit;
1581
- if (jsonMode) {
1582
- process.exit = ((code) => {
1583
- if (code && code !== 0) {
1584
- emitJson({ ok: false, command: commandType, data: null, error: lastErrorMsg || `Process exited with code ${code}`, duration: Date.now() - startTime });
1585
- }
1586
- origExit(code);
1696
+ process.exit = ((code) => {
1697
+ if (code && code !== 0) {
1698
+ emitJson({ ok: false, command, data: null, error: lastErrorMsg || `Process exited with code ${code}`, duration: Date.now() - startTime });
1699
+ }
1700
+ origExit(code);
1701
+ });
1702
+ return { origExit, getLastError: () => lastErrorMsg };
1703
+ }
1704
+ /**
1705
+ * Handle the `wait` command: wait for a server to be ready.
1706
+ */
1707
+ async function handleWaitCommand() {
1708
+ const waitUrl = getArg('--url') || 'http://localhost:3000';
1709
+ const waitTimeout = getArg('--timeout')
1710
+ ? parseInt(getArg('--timeout'), 10)
1711
+ : SERVER_READY_TIMEOUT;
1712
+ const waitStart = Date.now();
1713
+ try {
1714
+ await waitForServer(waitUrl, waitTimeout);
1715
+ console.log('[Sweetlink] ✓ Server is ready');
1716
+ return { url: waitUrl, ready: true, elapsed: Date.now() - waitStart };
1717
+ }
1718
+ catch (error) {
1719
+ console.error('[Sweetlink] ✗ Server not available:', error instanceof Error ? error.message : error);
1720
+ process.exit(1);
1721
+ }
1722
+ }
1723
+ /**
1724
+ * Handle the `status` command: quick non-blocking server health check.
1725
+ */
1726
+ async function handleStatusCommand() {
1727
+ const statusUrl = getArg('--url') || 'http://localhost:3000';
1728
+ try {
1729
+ const parsedUrl = new URL(statusUrl);
1730
+ const healthCheckUrl = `${parsedUrl.protocol}//${parsedUrl.host}`;
1731
+ const controller = new AbortController();
1732
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
1733
+ const response = await fetch(healthCheckUrl, {
1734
+ method: 'HEAD',
1735
+ signal: controller.signal,
1587
1736
  });
1737
+ clearTimeout(timeoutId);
1738
+ if (response.ok || response.status === 304) {
1739
+ console.log(`[Sweetlink] ✓ Server at ${healthCheckUrl} is running`);
1740
+ return { url: statusUrl, running: true, statusCode: response.status };
1741
+ }
1742
+ else {
1743
+ console.log(`[Sweetlink] ⚠ Server responded with status ${response.status}`);
1744
+ process.exit(1);
1745
+ }
1746
+ }
1747
+ catch {
1748
+ console.log(`[Sweetlink] ✗ Server at ${statusUrl} is not responding`);
1749
+ process.exit(1);
1750
+ }
1751
+ }
1752
+ (async () => {
1753
+ const startTime = Date.now();
1754
+ let origExit = process.exit;
1755
+ if (jsonMode) {
1756
+ const jsonSetup = setupJsonMode(commandType, startTime);
1757
+ origExit = jsonSetup.origExit;
1588
1758
  }
1589
1759
  try {
1590
1760
  let result;
@@ -1614,8 +1784,12 @@ const jsonMode = hasFlag('--json');
1614
1784
  console.error('[Sweetlink] Error: --selector is required for query command');
1615
1785
  process.exit(1);
1616
1786
  }
1617
- if (hasFlag('--url')) {
1618
- console.warn('[Sweetlink] Warning: --url is not supported for query (uses WebSocket bridge on current page)');
1787
+ if (getArg('--url')) {
1788
+ const navigated = await navigateBrowser(getArg('--url'));
1789
+ if (!navigated) {
1790
+ console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
1791
+ process.exit(1);
1792
+ }
1619
1793
  }
1620
1794
  result = await queryDOM({
1621
1795
  selector,
@@ -1641,8 +1815,12 @@ const jsonMode = hasFlag('--json');
1641
1815
  console.error('[Sweetlink] Error: --code is required for exec command');
1642
1816
  process.exit(1);
1643
1817
  }
1644
- if (hasFlag('--url')) {
1645
- console.warn('[Sweetlink] Warning: --url is not supported for exec (uses WebSocket bridge on current page)');
1818
+ if (getArg('--url')) {
1819
+ const navigated = await navigateBrowser(getArg('--url'));
1820
+ if (!navigated) {
1821
+ console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
1822
+ process.exit(1);
1823
+ }
1646
1824
  }
1647
1825
  result = await execJS({
1648
1826
  code,
@@ -1691,52 +1869,12 @@ const jsonMode = hasFlag('--json');
1691
1869
  });
1692
1870
  break;
1693
1871
  }
1694
- case 'wait': {
1695
- // Standalone wait command for waiting for server to be ready
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
- }
1872
+ case 'wait':
1873
+ result = await handleWaitCommand();
1710
1874
  break;
1711
- }
1712
- case 'status': {
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
- }
1875
+ case 'status':
1876
+ result = await handleStatusCommand();
1738
1877
  break;
1739
- }
1740
1878
  case 'schema':
1741
1879
  result = await getSchema({
1742
1880
  format: getArg('--format'),
@@ -1767,6 +1905,14 @@ const jsonMode = hasFlag('--json');
1767
1905
  verbose: hasFlag('--verbose'),
1768
1906
  });
1769
1907
  break;
1908
+ case 'setup': {
1909
+ // Run the setup script to symlink Claude context and skills
1910
+ const { execFileSync } = await import('child_process');
1911
+ const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
1912
+ const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
1913
+ execFileSync('node', [setupScript], { stdio: 'inherit' });
1914
+ break;
1915
+ }
1770
1916
  default:
1771
1917
  console.error(`[Sweetlink] Unknown command: ${commandType}`);
1772
1918
  console.log('Run "pnpm sweetlink --help" for usage information');