@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.
Files changed (43) hide show
  1. package/README.md +32 -23
  2. package/claude-skills/screenshot/SKILL.md +381 -0
  3. package/dist/browser/SweetlinkBridge.d.ts.map +1 -1
  4. package/dist/browser/SweetlinkBridge.js +1 -1
  5. package/dist/browser/SweetlinkBridge.js.map +1 -1
  6. package/dist/browser/commands/exec.d.ts.map +1 -1
  7. package/dist/browser/commands/exec.js +5 -2
  8. package/dist/browser/commands/exec.js.map +1 -1
  9. package/dist/browser/commands/index.d.ts +2 -2
  10. package/dist/browser/commands/index.d.ts.map +1 -1
  11. package/dist/browser/commands/index.js +2 -2
  12. package/dist/browser/commands/index.js.map +1 -1
  13. package/dist/browser/commands/outline.js.map +1 -1
  14. package/dist/browser/commands/schema.d.ts.map +1 -1
  15. package/dist/browser/commands/schema.js +10 -10
  16. package/dist/browser/commands/schema.js.map +1 -1
  17. package/dist/browser/commands/screenshot.d.ts.map +1 -1
  18. package/dist/browser/commands/screenshot.js +21 -3
  19. package/dist/browser/commands/screenshot.js.map +1 -1
  20. package/dist/browser/consoleCapture.d.ts.map +1 -1
  21. package/dist/browser/consoleCapture.js +3 -1
  22. package/dist/browser/consoleCapture.js.map +1 -1
  23. package/dist/browser.js +2 -3
  24. package/dist/browser.js.map +1 -1
  25. package/dist/cdp.d.ts +1 -1
  26. package/dist/cdp.d.ts.map +1 -1
  27. package/dist/cdp.js +1 -1
  28. package/dist/cdp.js.map +1 -1
  29. package/dist/cli/sweetlink.js +479 -256
  30. package/dist/cli/sweetlink.js.map +1 -1
  31. package/dist/playwright.d.ts.map +1 -1
  32. package/dist/playwright.js.map +1 -1
  33. package/dist/server/handlers/index.d.ts +2 -2
  34. package/dist/server/handlers/index.d.ts.map +1 -1
  35. package/dist/server/handlers/index.js +1 -1
  36. package/dist/server/handlers/index.js.map +1 -1
  37. package/dist/server/index.js +3 -3
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js.map +1 -1
  41. package/dist/viewportUtils.js.map +1 -1
  42. package/package.json +12 -3
  43. package/scripts/setup-claude-context.mjs +105 -68
@@ -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,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
- // 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 };
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
- 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 };
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 { path: getRelativePath(outputPath), width: data.width, height: data.height, method: 'WebSocket (html2canvas)', selector: data.selector };
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 { count: data.count, results: data.results, property: options.property };
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 { total: logs.length, format: 'json', deduped: !!options.dedupe, logs: processedLogs, outputPath: options.output };
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 { total: logs.length, format: 'summary', deduped: true, logs: deduped, outputPath: options.output };
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
- 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
- }
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
- 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);
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
- 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 = `
782
+ else {
783
+ findExpression = `Array.from(document.querySelectorAll(${escapedSelector}))`;
784
+ notFoundMsg = `"No element found matching: " + ${escapedSelector}`;
785
+ }
786
+ return `
672
787
  (() => {
673
- const searchText = ${escapedText};
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: "No element found with text: " + searchText };
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 { summary: result.summary, alignment: result.alignment, results: result.results, screenshotPath: result.screenshotPath };
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 { exec } = await import('child_process');
1096
+ const { execFile } = await import('child_process');
981
1097
  const { promisify } = await import('util');
982
- const execAsync = promisify(exec);
1098
+ const execFileAsync = promisify(execFile);
983
1099
  const lsofPath = await findLsofPath();
984
1100
  try {
985
- const { stdout } = await execAsync(`${lsofPath} -ti :${port}`);
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 execAsync(`/bin/kill -9 ${pid}`);
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
- 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]
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 (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
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> Target URL to navigate to (default: http://localhost:3000)
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 (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)
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
- 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)
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
- query --selector <css-selector> [options]
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 "img" --wait-for "img[src*='hero']"
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 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
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 = ['screenshot', 'query', 'logs', 'exec', 'click', 'refresh', 'ruler', 'measure', 'network', 'schema', 'outline', 'a11y', 'accessibility', 'vitals', 'cleanup', 'wait', 'status'];
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
- ? (commandType === 'measure' ? 'ruler' : commandType === 'accessibility' ? 'a11y' : commandType)
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
- (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
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
- 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
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
- 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);
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 (hasFlag('--url')) {
1618
- console.warn('[Sweetlink] Warning: --url is not supported for query (uses WebSocket bridge on current page)');
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') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
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 (hasFlag('--url')) {
1645
- console.warn('[Sweetlink] Warning: --url is not supported for exec (uses WebSocket bridge on current page)');
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') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
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
- // 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
- }
1943
+ case 'wait':
1944
+ result = await handleWaitCommand();
1710
1945
  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
- }
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({ ok: false, command: commandType, data: null, error: msg, duration: Date.now() - startTime });
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);