@ytspar/sweetlink 1.19.0 → 1.21.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.
Files changed (94) hide show
  1. package/README.md +40 -0
  2. package/dist/cli/outputSchemas.d.ts +57 -1
  3. package/dist/cli/outputSchemas.d.ts.map +1 -1
  4. package/dist/cli/outputSchemas.js +36 -1
  5. package/dist/cli/outputSchemas.js.map +1 -1
  6. package/dist/cli/sweetlink.js +400 -48
  7. package/dist/cli/sweetlink.js.map +1 -1
  8. package/dist/daemon/browser.d.ts.map +1 -1
  9. package/dist/daemon/browser.js.map +1 -1
  10. package/dist/daemon/client.d.ts +7 -0
  11. package/dist/daemon/client.d.ts.map +1 -1
  12. package/dist/daemon/client.js +16 -2
  13. package/dist/daemon/client.js.map +1 -1
  14. package/dist/daemon/demo.d.ts.map +1 -1
  15. package/dist/daemon/demo.js +6 -2
  16. package/dist/daemon/demo.js.map +1 -1
  17. package/dist/daemon/diff.d.ts.map +1 -1
  18. package/dist/daemon/diff.js +5 -3
  19. package/dist/daemon/diff.js.map +1 -1
  20. package/dist/daemon/evidence.d.ts.map +1 -1
  21. package/dist/daemon/evidence.js +6 -6
  22. package/dist/daemon/evidence.js.map +1 -1
  23. package/dist/daemon/index.js +1 -1
  24. package/dist/daemon/index.js.map +1 -1
  25. package/dist/daemon/listeners.d.ts.map +1 -1
  26. package/dist/daemon/listeners.js +8 -6
  27. package/dist/daemon/listeners.js.map +1 -1
  28. package/dist/daemon/recording.d.ts +5 -0
  29. package/dist/daemon/recording.d.ts.map +1 -1
  30. package/dist/daemon/recording.js +38 -15
  31. package/dist/daemon/recording.js.map +1 -1
  32. package/dist/daemon/refs.d.ts.map +1 -1
  33. package/dist/daemon/refs.js +1 -1
  34. package/dist/daemon/refs.js.map +1 -1
  35. package/dist/daemon/ringBuffer.d.ts +8 -0
  36. package/dist/daemon/ringBuffer.d.ts.map +1 -1
  37. package/dist/daemon/ringBuffer.js +17 -0
  38. package/dist/daemon/ringBuffer.js.map +1 -1
  39. package/dist/daemon/server.d.ts.map +1 -1
  40. package/dist/daemon/server.js +490 -86
  41. package/dist/daemon/server.js.map +1 -1
  42. package/dist/daemon/stateFile.js +2 -2
  43. package/dist/daemon/stateFile.js.map +1 -1
  44. package/dist/daemon/summary.d.ts +1 -1
  45. package/dist/daemon/summary.d.ts.map +1 -1
  46. package/dist/daemon/summary.js +2 -2
  47. package/dist/daemon/summary.js.map +1 -1
  48. package/dist/daemon/types.d.ts +1 -1
  49. package/dist/daemon/types.d.ts.map +1 -1
  50. package/dist/daemon/types.js.map +1 -1
  51. package/dist/daemon/viewer.d.ts +1 -1
  52. package/dist/daemon/viewer.d.ts.map +1 -1
  53. package/dist/daemon/viewer.js +21 -13
  54. package/dist/daemon/viewer.js.map +1 -1
  55. package/dist/daemon/visualDiff.js +1 -1
  56. package/dist/daemon/visualDiff.js.map +1 -1
  57. package/dist/next.js +3 -3
  58. package/dist/next.js.map +1 -1
  59. package/dist/ruler.js +3 -1
  60. package/dist/ruler.js.map +1 -1
  61. package/dist/runs.d.ts +34 -0
  62. package/dist/runs.d.ts.map +1 -0
  63. package/dist/runs.js +61 -0
  64. package/dist/runs.js.map +1 -0
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +20 -10
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/simulator/android.d.ts +13 -0
  69. package/dist/simulator/android.d.ts.map +1 -1
  70. package/dist/simulator/android.js +75 -5
  71. package/dist/simulator/android.js.map +1 -1
  72. package/dist/simulator/androidTaps.d.ts +99 -0
  73. package/dist/simulator/androidTaps.d.ts.map +1 -0
  74. package/dist/simulator/androidTaps.js +162 -0
  75. package/dist/simulator/androidTaps.js.map +1 -0
  76. package/dist/simulator/ios.d.ts.map +1 -1
  77. package/dist/simulator/ios.js +13 -5
  78. package/dist/simulator/ios.js.map +1 -1
  79. package/dist/simulator/overlay.d.ts +41 -0
  80. package/dist/simulator/overlay.d.ts.map +1 -0
  81. package/dist/simulator/overlay.js +78 -0
  82. package/dist/simulator/overlay.js.map +1 -0
  83. package/dist/term/ansi.d.ts.map +1 -1
  84. package/dist/term/ansi.js +49 -14
  85. package/dist/term/ansi.js.map +1 -1
  86. package/dist/term/player.d.ts.map +1 -1
  87. package/dist/term/player.js +5 -5
  88. package/dist/term/player.js.map +1 -1
  89. package/dist/term/recorder.js +1 -1
  90. package/dist/term/recorder.js.map +1 -1
  91. package/dist/vite.d.ts.map +1 -1
  92. package/dist/vite.js +8 -4
  93. package/dist/vite.js.map +1 -1
  94. package/package.json +1 -1
@@ -9,13 +9,13 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { WebSocket } from 'ws';
11
11
  import { detectCDP, getNetworkRequestsViaCDP } from '../cdp.js';
12
+ import { DaemonRequestError, daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon, } from '../daemon/client.js';
13
+ import { uploadEvidence } from '../daemon/evidence.js';
14
+ import { extractPort } from '../daemon/stateFile.js';
12
15
  import { screenshotViaPlaywright } from '../playwright.js';
13
16
  import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '../ruler.js';
14
17
  import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
15
18
  import { SCREENSHOT_DIR } from '../urlUtils.js';
16
- import { daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon } from '../daemon/client.js';
17
- import { extractPort } from '../daemon/stateFile.js';
18
- import { uploadEvidence } from '../daemon/evidence.js';
19
19
  import { emitJson, printOutputSchema } from './outputSchemas.js';
20
20
  const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
21
21
  /**
@@ -118,7 +118,9 @@ function reportScreenshotSuccess(outputPath, width, height, method, selector) {
118
118
  try {
119
119
  sizeKb = ` · ${Math.round(fs.statSync(outputPath).size / 1024)}KB`;
120
120
  }
121
- catch { /* file may have been moved or removed */ }
121
+ catch {
122
+ /* file may have been moved or removed */
123
+ }
122
124
  const selPart = selector ? ` · ${selector}` : '';
123
125
  if (process.env.SWEETLINK_VERBOSE === '1') {
124
126
  console.log(`[Sweetlink] ✓ Screenshot saved to: ${getRelativePath(outputPath)}`);
@@ -188,8 +190,7 @@ async function discoverServer(target) {
188
190
  }
189
191
  const lowerTarget = target.toLowerCase();
190
192
  // Exact match on branch or app name
191
- const exact = results.find((r) => r.gitBranch?.toLowerCase() === lowerTarget ||
192
- r.appName?.toLowerCase() === lowerTarget);
193
+ const exact = results.find((r) => r.gitBranch?.toLowerCase() === lowerTarget || r.appName?.toLowerCase() === lowerTarget);
193
194
  if (exact)
194
195
  return `ws://localhost:${exact.port}`;
195
196
  // Partial match (branch contains target)
@@ -485,8 +486,11 @@ async function screenshot(options) {
485
486
  `Use --index N (with click) or a more specific selector to pick another.`);
486
487
  }
487
488
  // UX: hint at --full-page when content extends below the viewport.
488
- if (!options.selector && !options.fullPage &&
489
- data.pageHeight && data.viewportHeight && data.pageHeight > data.viewportHeight + 4) {
489
+ if (!options.selector &&
490
+ !options.fullPage &&
491
+ data.pageHeight &&
492
+ data.viewportHeight &&
493
+ data.pageHeight > data.viewportHeight + 4) {
490
494
  const overflow = data.pageHeight - data.viewportHeight;
491
495
  console.log(`[Sweetlink] ℹ Page extends ${overflow}px below the viewport. ` +
492
496
  `Use --full-page to capture all of it.`);
@@ -1547,6 +1551,27 @@ async function getVitals(options) {
1547
1551
  process.exit(1);
1548
1552
  }
1549
1553
  }
1554
+ function printInspectSummary(data) {
1555
+ console.log('\n[Sweetlink] Agent Inspect');
1556
+ console.log(` URL: ${data.url}`);
1557
+ console.log(` Title: ${data.title || '(untitled)'}`);
1558
+ console.log(` Viewport: ${data.viewport.width}x${data.viewport.height}` +
1559
+ (data.viewport.deviceScaleFactor ? ` @${data.viewport.deviceScaleFactor}x` : ''));
1560
+ console.log(` Artifacts: ${getRelativePath(data.artifacts.dir)}`);
1561
+ console.log(` Refs: ${data.counts.refs} | Console: ${data.counts.consoleErrors} errors, ${data.counts.consoleWarnings} warnings | Network: ${data.counts.networkFailures} failures`);
1562
+ if (data.counts.a11yViolations !== undefined) {
1563
+ console.log(` A11y: ${data.counts.a11yViolations} violations, ${data.counts.a11yIncomplete ?? 0} incomplete`);
1564
+ }
1565
+ if (data.nextActions.length > 0) {
1566
+ console.log('\n Suggested next actions:');
1567
+ for (const action of data.nextActions) {
1568
+ console.log(` - ${action}`);
1569
+ }
1570
+ }
1571
+ console.log(`\n Summary: ${getRelativePath(data.artifacts.summaryMarkdown)}`);
1572
+ console.log(` JSON: ${getRelativePath(data.artifacts.contextJson)}`);
1573
+ console.log(` PNG: ${getRelativePath(data.artifacts.screenshotPng)}`);
1574
+ }
1550
1575
  // Per-command help text, keyed by canonical command name
1551
1576
  const COMMAND_HELP = {
1552
1577
  screenshot: ` screenshot [options]
@@ -1595,6 +1620,30 @@ const COMMAND_HELP = {
1595
1620
  pnpm sweetlink screenshot --force-cdp --width 375 --height 667 # Playwright at iPhone SE
1596
1621
  pnpm sweetlink screenshot --hifi # Pixel-perfect via daemon
1597
1622
  pnpm sweetlink screenshot --responsive # 3 breakpoints via daemon`,
1623
+ inspect: ` inspect [options] (alias: context)
1624
+ Capture one LLM-ready frontend context bundle from the daemon.
1625
+
1626
+ Includes:
1627
+ - Full-page screenshot artifact
1628
+ - Interactive @e refs and accessibility snapshot
1629
+ - Console and network deltas from daemon buffers
1630
+ - Axe accessibility summary when axe-core is available
1631
+ - Page timing/viewport metadata and suggested next actions
1632
+
1633
+ Options:
1634
+ --url <url> Target URL (default: http://localhost:3000)
1635
+ --last <number> Console/network entries to include (default: 50)
1636
+ --label <text> Scenario label for artifact directory
1637
+ --expected <text> Expected outcome to embed in the bundle
1638
+ --action <text> Action transcript item (repeatable)
1639
+ --no-a11y Skip axe accessibility audit
1640
+ --format <type> Output format: text (default), json
1641
+ --output <path> Also write the context JSON to this path
1642
+
1643
+ Examples:
1644
+ pnpm sweetlink inspect --url http://localhost:5173
1645
+ pnpm sweetlink context --label "checkout empty state" --expected "CTA is visible"
1646
+ pnpm sweetlink inspect --format json --output .tmp/inspect.json`,
1598
1647
  query: ` query --selector <css-selector> [options]
1599
1648
  Query DOM elements and return data
1600
1649
 
@@ -1886,15 +1935,21 @@ const COMMAND_HELP = {
1886
1935
  --label <text> Embedded in filename
1887
1936
  --device <name|udid> Pick a specific simulator/emulator (default: first booted)
1888
1937
  --time-limit <sec> Android only — caps screen recording (max 180)
1938
+ --app <name> Group artifacts under .sweetlink/<app>/<YYYYMMDD>/<run>/sim/
1939
+ --run <id> Override the auto-generated run id
1940
+ --no-overlays Android only — skip tap-event capture and ffmpeg overlay
1889
1941
  --ignore-exit Don't propagate the recorded command's exit code
1890
1942
 
1891
1943
  Requirements:
1892
1944
  iOS: Xcode + a booted Simulator (Simulator.app)
1893
1945
  Android: Android Platform Tools (\`adb\`) + a running emulator
1946
+ Tap-indicator overlays additionally need \`ffmpeg\` on PATH;
1947
+ without it, taps are still captured to a sidecar .taps.json.
1894
1948
 
1895
1949
  Examples:
1896
1950
  pnpm sweetlink sim ios "fastlane scan" --device "iPhone 15"
1897
- pnpm sweetlink sim android "./gradlew connectedAndroidTest"`,
1951
+ pnpm sweetlink sim android "./gradlew connectedAndroidTest"
1952
+ pnpm sweetlink sim android "appium run" --no-overlays`,
1898
1953
  term: ` term <command...>
1899
1954
  Record a shell command's stdout/stderr into asciicast v2 + a self-contained
1900
1955
  HTML player. Captures real timing; the player has play/pause, 0.1×–4×
@@ -1903,6 +1958,8 @@ const COMMAND_HELP = {
1903
1958
  Options:
1904
1959
  --output <path> .cast file path (default: .sweetlink/term/<label>-<stamp>.cast)
1905
1960
  --label <text> Label embedded in the .cast title + filename
1961
+ --app <name> Group artifacts under .sweetlink/<app>/<YYYYMMDD>/<run>/term/
1962
+ --run <id> Override the auto-generated run id (HHMM-SS or $SWEETLINK_RUN)
1906
1963
  --shell <path> Shell to invoke the command in (default: /bin/sh)
1907
1964
  --cols <n> Reported terminal width (default: 120)
1908
1965
  --rows <n> Reported terminal height (default: 30)
@@ -1910,7 +1967,7 @@ const COMMAND_HELP = {
1910
1967
 
1911
1968
  Examples:
1912
1969
  pnpm sweetlink term "pytest -v" --label api-tests
1913
- pnpm sweetlink term "go test ./..." --label go-tests
1970
+ pnpm sweetlink term "go test ./..." --app my-app --label go-tests
1914
1971
  pnpm sweetlink term "make build" --output .sweetlink/term/build.cast`,
1915
1972
  sessions: ` sessions [list|open]
1916
1973
  List or open all recorded sessions in this project.
@@ -1970,6 +2027,7 @@ const COMMAND_HELP = {
1970
2027
  const COMMAND_ALIASES = {
1971
2028
  measure: 'ruler',
1972
2029
  accessibility: 'a11y',
2030
+ context: 'inspect',
1973
2031
  };
1974
2032
  const GLOBAL_HELP = `
1975
2033
  Global Flags:
@@ -2010,9 +2068,12 @@ Commands:`);
2010
2068
  // Each block looks like:
2011
2069
  // " command [args]\n First-line description.\n..."
2012
2070
  // We pick the first non-empty line after the signature.
2013
- const lines = help.split('\n').map((l) => l.trim()).filter(Boolean);
2071
+ const lines = help
2072
+ .split('\n')
2073
+ .map((l) => l.trim())
2074
+ .filter(Boolean);
2014
2075
  const desc = lines[1] ?? '';
2015
- const summary = desc.length > 70 ? desc.slice(0, 67) + '…' : desc;
2076
+ const summary = desc.length > 70 ? `${desc.slice(0, 67)}…` : desc;
2016
2077
  console.log(` ${name.padEnd(14)} ${summary}`);
2017
2078
  }
2018
2079
  if (process.argv.includes('--all')) {
@@ -2039,12 +2100,161 @@ function showCommandHelp(command) {
2039
2100
  }
2040
2101
  // CLI argument parsing
2041
2102
  const args = process.argv.slice(2);
2042
- // Skip global flags to find the actual command
2043
- const commandType = args.find((a) => !a.startsWith('--')) || args[0];
2103
+ // Skip global flags to find the actual command. Falling back to args[0]
2104
+ // would surface flags like `--json` as the command — we want undefined
2105
+ // in that case so the no-command paths (help / batch mode) can fire.
2106
+ const commandType = args.find((a) => !a.startsWith('--'));
2044
2107
  if (!commandType || commandType === '--help' || commandType === '-h') {
2108
+ // `sweetlink --json` with stdin → multi-capture batch mode.
2109
+ // Reads { action: "capture", captures: [...] } from stdin and runs
2110
+ // each entry, aggregating results into a single JSON envelope on stdout.
2111
+ if (args.includes('--json') && !process.stdin.isTTY) {
2112
+ // Top-level await keeps the rest of the dispatch from running while
2113
+ // the batch is in flight; we exit before fallthrough either way.
2114
+ try {
2115
+ await runBatchFromStdin();
2116
+ process.exit(0);
2117
+ }
2118
+ catch (err) {
2119
+ const msg = err instanceof Error ? err.message : String(err);
2120
+ process.stdout.write(`${JSON.stringify({ ok: false, error: msg })}\n`);
2121
+ process.exit(1);
2122
+ }
2123
+ }
2045
2124
  showHelp();
2046
2125
  process.exit(0);
2047
2126
  }
2127
+ async function runBatchFromStdin() {
2128
+ const chunks = [];
2129
+ for await (const chunk of process.stdin)
2130
+ chunks.push(chunk);
2131
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
2132
+ if (!raw) {
2133
+ throw new Error('No input on stdin. Pipe a JSON document with {"action":"capture","captures":[...]}.');
2134
+ }
2135
+ let body;
2136
+ try {
2137
+ body = JSON.parse(raw);
2138
+ }
2139
+ catch (e) {
2140
+ throw new Error(`Could not parse stdin as JSON: ${e instanceof Error ? e.message : e}`);
2141
+ }
2142
+ if (body.action !== 'capture' || !Array.isArray(body.captures)) {
2143
+ throw new Error('Expected { "action": "capture", "captures": [...] }.');
2144
+ }
2145
+ const startTime = Date.now();
2146
+ const results = [];
2147
+ for (const cap of body.captures) {
2148
+ const t0 = Date.now();
2149
+ const mode = cap.mode;
2150
+ const label = cap.label;
2151
+ try {
2152
+ const data = await runOneBatchCapture(cap);
2153
+ results.push({ ok: true, mode, label, data, duration: Date.now() - t0 });
2154
+ }
2155
+ catch (err) {
2156
+ results.push({
2157
+ ok: false,
2158
+ mode,
2159
+ label,
2160
+ error: err instanceof Error ? err.message : String(err),
2161
+ duration: Date.now() - t0,
2162
+ });
2163
+ }
2164
+ }
2165
+ const allOk = results.every((r) => r.ok);
2166
+ process.stdout.write(`${JSON.stringify({
2167
+ ok: allOk,
2168
+ duration: Date.now() - startTime,
2169
+ captures: results,
2170
+ })}\n`);
2171
+ if (!allOk)
2172
+ process.exit(1);
2173
+ }
2174
+ async function runOneBatchCapture(cap) {
2175
+ const mode = cap.mode;
2176
+ if (!mode)
2177
+ throw new Error('Capture entry missing required "mode" field.');
2178
+ if (mode === 'term') {
2179
+ const command = cap.command;
2180
+ if (!command)
2181
+ throw new Error('term capture missing "command".');
2182
+ const label = cap.label ?? 'batch';
2183
+ const labelSlug = label
2184
+ .replace(/[^a-z0-9]/gi, '-')
2185
+ .toLowerCase()
2186
+ .slice(0, 40);
2187
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2188
+ const output = cap.output ??
2189
+ path.join(findProjectRoot(), '.sweetlink', 'term', `${labelSlug}-${stamp}.cast`);
2190
+ ensureDir(output);
2191
+ const { captureTerminal } = await import('../term/recorder.js');
2192
+ const { generatePlayer } = await import('../term/player.js');
2193
+ const result = await captureTerminal({
2194
+ command,
2195
+ output,
2196
+ label,
2197
+ shell: cap.shell,
2198
+ cols: typeof cap.cols === 'number' ? cap.cols : undefined,
2199
+ rows: typeof cap.rows === 'number' ? cap.rows : undefined,
2200
+ });
2201
+ const playerPath = await generatePlayer({ castPath: output });
2202
+ return {
2203
+ castPath: output,
2204
+ playerPath,
2205
+ durationSec: result.durationSec,
2206
+ exitCode: result.exitCode,
2207
+ events: result.events,
2208
+ };
2209
+ }
2210
+ if (mode === 'sim-ios' || mode === 'sim-android') {
2211
+ const command = cap.command;
2212
+ if (!command)
2213
+ throw new Error(`${mode} capture missing "command".`);
2214
+ const label = cap.label ?? 'batch';
2215
+ const labelSlug = label
2216
+ .replace(/[^a-z0-9]/gi, '-')
2217
+ .toLowerCase()
2218
+ .slice(0, 40);
2219
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2220
+ const output = cap.output ??
2221
+ path.join(findProjectRoot(), '.sweetlink', 'sim', `${labelSlug}-${stamp}.mp4`);
2222
+ ensureDir(output);
2223
+ const device = cap.device;
2224
+ if (mode === 'sim-ios') {
2225
+ const { recordIosSimulator } = await import('../simulator/ios.js');
2226
+ return recordIosSimulator({ command, output, device });
2227
+ }
2228
+ const { recordAndroidEmulator } = await import('../simulator/android.js');
2229
+ return recordAndroidEmulator({
2230
+ command,
2231
+ output,
2232
+ device,
2233
+ timeLimit: typeof cap.timeLimit === 'number' ? cap.timeLimit : undefined,
2234
+ });
2235
+ }
2236
+ if (mode === 'screenshot') {
2237
+ const targetUrl = cap.url ?? 'http://localhost:3000';
2238
+ const projRoot = findProjectRoot();
2239
+ const state = await ensureDaemon(projRoot, targetUrl);
2240
+ const resp = await daemonRequest(state, 'screenshot', {
2241
+ selector: cap.selector,
2242
+ fullPage: cap.fullPage,
2243
+ viewport: cap.viewport,
2244
+ padding: cap.padding,
2245
+ theme: cap.theme,
2246
+ });
2247
+ const data = resp.data;
2248
+ if (cap.output) {
2249
+ const outputPath = cap.output;
2250
+ ensureDir(outputPath);
2251
+ fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
2252
+ return { path: outputPath, width: data.width, height: data.height };
2253
+ }
2254
+ return { width: data.width, height: data.height, base64Length: data.screenshot.length };
2255
+ }
2256
+ throw new Error(`Unknown capture mode: ${mode}. Allowed: term, sim-ios, sim-android, screenshot.`);
2257
+ }
2048
2258
  // Helper function to get argument value
2049
2259
  function getArg(flag) {
2050
2260
  const index = args.indexOf(flag);
@@ -2054,6 +2264,7 @@ function hasFlag(flag) {
2054
2264
  return args.includes(flag);
2055
2265
  }
2056
2266
  // Per-command --help: `pnpm sweetlink screenshot --help`
2267
+ // Past the early-exit at the top of dispatch, commandType is non-null.
2057
2268
  if (hasFlag('--help') || hasFlag('-h')) {
2058
2269
  showCommandHelp(commandType);
2059
2270
  process.exit(0);
@@ -2063,6 +2274,8 @@ if (hasFlag('--output-schema')) {
2063
2274
  // If commandType is a known command, print just that schema; otherwise print all
2064
2275
  const knownCommands = [
2065
2276
  'screenshot',
2277
+ 'inspect',
2278
+ 'context',
2066
2279
  'query',
2067
2280
  'logs',
2068
2281
  'exec',
@@ -2096,7 +2309,9 @@ if (hasFlag('--output-schema')) {
2096
2309
  ? 'ruler'
2097
2310
  : commandType === 'accessibility'
2098
2311
  ? 'a11y'
2099
- : commandType
2312
+ : commandType === 'context'
2313
+ ? 'inspect'
2314
+ : commandType
2100
2315
  : undefined;
2101
2316
  printOutputSchema(schemaCommand);
2102
2317
  process.exit(0);
@@ -2130,6 +2345,24 @@ function setupJsonMode(command, startTime) {
2130
2345
  });
2131
2346
  return { origExit, getLastError: () => lastErrorMsg };
2132
2347
  }
2348
+ function getErrorData(error) {
2349
+ if (error instanceof DaemonRequestError) {
2350
+ return {
2351
+ action: error.action,
2352
+ status: error.status,
2353
+ ...(error.data ? error.data : {}),
2354
+ };
2355
+ }
2356
+ return null;
2357
+ }
2358
+ function printErrorContext(error) {
2359
+ if (!(error instanceof DaemonRequestError) || !error.data)
2360
+ return;
2361
+ const failureScreenshot = error.data.failureScreenshot;
2362
+ if (typeof failureScreenshot === 'string') {
2363
+ console.error(`[Sweetlink] Failure screenshot: ${failureScreenshot}`);
2364
+ }
2365
+ }
2133
2366
  /**
2134
2367
  * Handle the `wait` command: wait for a server to be ready.
2135
2368
  */
@@ -2180,9 +2413,14 @@ async function handleStatusCommand() {
2180
2413
  }
2181
2414
  (async () => {
2182
2415
  const startTime = Date.now();
2183
- // Resolve --app flag: discover the matching Sweetlink server by branch/app name
2416
+ // Resolve --app flag: for WS-bridge commands, this discovers the matching
2417
+ // Sweetlink server by branch/app name. For commands that produce artifacts
2418
+ // (term/sim), --app is a *namespace* used in the artifact directory layout
2419
+ // (.sweetlink/<app>/<YYYYMMDD>/<run>/...) — those handlers read --app
2420
+ // themselves, so we skip discovery here.
2184
2421
  const appTarget = getArg('--app');
2185
- if (appTarget) {
2422
+ const isArtifactCommand = commandType === 'term' || commandType === 'sim';
2423
+ if (appTarget && !isArtifactCommand) {
2186
2424
  try {
2187
2425
  resolvedWsUrl = await discoverServer(appTarget);
2188
2426
  console.log(`[Sweetlink] Targeting server: ${resolvedWsUrl}`);
@@ -2223,6 +2461,42 @@ async function handleStatusCommand() {
2223
2461
  : undefined,
2224
2462
  });
2225
2463
  break;
2464
+ case 'inspect':
2465
+ case 'context': {
2466
+ const projRoot = findProjectRoot();
2467
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2468
+ const state = await ensureDaemon(projRoot, targetUrl);
2469
+ const actionTranscript = [];
2470
+ args.forEach((arg, index) => {
2471
+ if (arg !== '--action')
2472
+ return;
2473
+ const value = args[index + 1];
2474
+ if (!value)
2475
+ return;
2476
+ actionTranscript.push({ action: value });
2477
+ });
2478
+ const resp = await daemonRequest(state, 'inspect', {
2479
+ last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
2480
+ label: getArg('--label'),
2481
+ expectedOutcome: getArg('--expected'),
2482
+ actionTranscript,
2483
+ includeA11y: !hasFlag('--no-a11y'),
2484
+ });
2485
+ const data = resp.data;
2486
+ const output = getArg('--output');
2487
+ if (output) {
2488
+ ensureDir(output);
2489
+ fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
2490
+ }
2491
+ if (getArg('--format') === 'json') {
2492
+ console.log(JSON.stringify(data, null, 2));
2493
+ }
2494
+ else {
2495
+ printInspectSummary(data);
2496
+ }
2497
+ result = data;
2498
+ break;
2499
+ }
2226
2500
  case 'query': {
2227
2501
  const selector = getArg('--selector');
2228
2502
  if (!selector) {
@@ -2318,7 +2592,9 @@ async function handleStatusCommand() {
2318
2592
  }
2319
2593
  }
2320
2594
  }
2321
- catch { /* fall through to WS path */ }
2595
+ catch {
2596
+ /* fall through to WS path */
2597
+ }
2322
2598
  result = await click({
2323
2599
  selector: clickTarget,
2324
2600
  text: clickText,
@@ -2446,7 +2722,8 @@ async function handleStatusCommand() {
2446
2722
  }
2447
2723
  const sessionDirArg = getArg('--session') ?? '.sweetlink';
2448
2724
  // Find latest session manifest
2449
- const sessionFiles = fs.readdirSync(sessionDirArg)
2725
+ const sessionFiles = fs
2726
+ .readdirSync(sessionDirArg)
2450
2727
  .filter((f) => f.startsWith('session-'))
2451
2728
  .sort()
2452
2729
  .reverse();
@@ -2501,16 +2778,19 @@ async function handleStatusCommand() {
2501
2778
  const data = resp.data;
2502
2779
  const m = data.manifest;
2503
2780
  console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
2504
- console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ' | Video: ' + m.video : ''}`);
2781
+ console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
2505
2782
  // Auto-open the viewer (cross-platform; --no-open to suppress)
2506
2783
  if (data.viewerPath && !hasFlag('--no-open')) {
2507
2784
  console.log(` Viewer: ${data.viewerPath}`);
2508
2785
  const { execFile } = await import('child_process');
2509
2786
  // `start` on Windows is a cmd builtin, not an exe — must invoke via cmd.
2510
- const cmd = process.platform === 'darwin' ? 'open'
2511
- : process.platform === 'win32' ? 'cmd'
2787
+ const cmd = process.platform === 'darwin'
2788
+ ? 'open'
2789
+ : process.platform === 'win32'
2790
+ ? 'cmd'
2512
2791
  : 'xdg-open';
2513
- const args = process.platform === 'win32' ? ['/c', 'start', '', data.viewerPath]
2792
+ const args = process.platform === 'win32'
2793
+ ? ['/c', 'start', '', data.viewerPath]
2514
2794
  : [data.viewerPath];
2515
2795
  execFile(cmd, args, (err) => {
2516
2796
  if (err)
@@ -2553,7 +2833,10 @@ async function handleStatusCommand() {
2553
2833
  const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
2554
2834
  const startData = startResp.data;
2555
2835
  console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
2556
- const steps = script.split(';').map((s) => s.trim()).filter(Boolean);
2836
+ const steps = script
2837
+ .split(';')
2838
+ .map((s) => s.trim())
2839
+ .filter(Boolean);
2557
2840
  // Snapshot once up-front so refs resolve.
2558
2841
  await daemonRequest(state, 'snapshot', { interactive: true });
2559
2842
  for (const step of steps) {
@@ -2637,7 +2920,8 @@ async function handleStatusCommand() {
2637
2920
  console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
2638
2921
  process.exit(1);
2639
2922
  }
2640
- const reportSessions = fs.readdirSync(sessionDirArg)
2923
+ const reportSessions = fs
2924
+ .readdirSync(sessionDirArg)
2641
2925
  .filter((f) => f.startsWith('session-'))
2642
2926
  .sort()
2643
2927
  .reverse();
@@ -2714,7 +2998,10 @@ async function handleStatusCommand() {
2714
2998
  }
2715
2999
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
2716
3000
  const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
2717
- const payload = { summary, manifest };
3001
+ const payload = {
3002
+ summary,
3003
+ manifest,
3004
+ };
2718
3005
  // Include viewer HTML for Slack/Discord webhooks
2719
3006
  if (/slack|discord/i.test(webhookUrl)) {
2720
3007
  const viewerPath = path.join(reportSessionDir, 'viewer.html');
@@ -2758,7 +3045,14 @@ async function handleStatusCommand() {
2758
3045
  console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
2759
3046
  process.exit(1);
2760
3047
  }
2761
- const flagsWithValues = new Set(['--output', '--label', '--device', '--time-limit']);
3048
+ const flagsWithValues = new Set([
3049
+ '--output',
3050
+ '--label',
3051
+ '--device',
3052
+ '--time-limit',
3053
+ '--app',
3054
+ '--run',
3055
+ ]);
2762
3056
  const positional = [];
2763
3057
  for (let i = 2; i < args.length; i++) {
2764
3058
  const a = args[i];
@@ -2776,10 +3070,19 @@ async function handleStatusCommand() {
2776
3070
  }
2777
3071
  const label = getArg('--label');
2778
3072
  const labelSlug = label
2779
- ? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
3073
+ ? label
3074
+ .replace(/[^a-z0-9]/gi, '-')
3075
+ .toLowerCase()
3076
+ .slice(0, 40)
2780
3077
  : `sim-${platform}`;
2781
3078
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2782
- const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'sim');
3079
+ const { runSlot: simRunSlot } = await import('../runs.js');
3080
+ const defaultDir = simRunSlot({
3081
+ baseDir: findProjectRoot(),
3082
+ app: getArg('--app'),
3083
+ run: getArg('--run'),
3084
+ kind: 'sim',
3085
+ });
2783
3086
  const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
2784
3087
  ensureDir(output);
2785
3088
  const device = getArg('--device');
@@ -2793,24 +3096,38 @@ async function handleStatusCommand() {
2793
3096
  const { recordAndroidEmulator } = await import('../simulator/android.js');
2794
3097
  const tl = getArg('--time-limit');
2795
3098
  recResult = await recordAndroidEmulator({
2796
- command, output, device,
3099
+ command,
3100
+ output,
3101
+ device,
2797
3102
  timeLimit: tl ? parseInt(tl, 10) : undefined,
3103
+ overlays: !hasFlag('--no-overlays'),
2798
3104
  });
2799
3105
  }
2800
3106
  let sizeKb = '?';
2801
3107
  try {
2802
3108
  sizeKb = String(Math.round(fs.statSync(output).size / 1024));
2803
3109
  }
2804
- catch { /* file may not exist if recordingClosed is false */ }
3110
+ catch {
3111
+ /* file may not exist if recordingClosed is false */
3112
+ }
3113
+ const tapSuffix = (recResult.tapCount ?? 0) > 0
3114
+ ? ` · ${recResult.tapCount} taps${recResult.overlaysApplied ? ' (overlaid)' : ' (sidecar only — install ffmpeg for overlays)'}`
3115
+ : '';
2805
3116
  console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
2806
3117
  `${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
2807
- (recResult.recordingClosed ? '' : ' (recording was force-killed; mp4 may be incomplete)'));
3118
+ tapSuffix +
3119
+ (recResult.recordingClosed
3120
+ ? ''
3121
+ : ' (recording was force-killed; mp4 may be incomplete)'));
2808
3122
  result = {
2809
3123
  path: output,
2810
3124
  device: recResult.device,
2811
3125
  durationSec: recResult.durationSec,
2812
3126
  exitCode: recResult.exitCode,
2813
3127
  recordingClosed: recResult.recordingClosed,
3128
+ tapCount: recResult.tapCount,
3129
+ tapsJsonPath: recResult.tapsJsonPath,
3130
+ overlaysApplied: recResult.overlaysApplied,
2814
3131
  };
2815
3132
  if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
2816
3133
  process.exit(recResult.exitCode);
@@ -2819,8 +3136,16 @@ async function handleStatusCommand() {
2819
3136
  }
2820
3137
  case 'term': {
2821
3138
  // Record a shell command's stdout/stderr into asciicast v2 + HTML player.
2822
- // Example: sweetlink term "pytest -v" --label api-tests
2823
- const flagsWithValues = new Set(['--output', '--label', '--shell', '--cols', '--rows']);
3139
+ // Example: sweetlink term "pytest -v" --label api-tests --app my-app
3140
+ const flagsWithValues = new Set([
3141
+ '--output',
3142
+ '--label',
3143
+ '--shell',
3144
+ '--cols',
3145
+ '--rows',
3146
+ '--app',
3147
+ '--run',
3148
+ ]);
2824
3149
  const positional = [];
2825
3150
  for (let i = 1; i < args.length; i++) {
2826
3151
  const a = args[i];
@@ -2838,10 +3163,19 @@ async function handleStatusCommand() {
2838
3163
  }
2839
3164
  const label = getArg('--label');
2840
3165
  const labelSlug = label
2841
- ? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
3166
+ ? label
3167
+ .replace(/[^a-z0-9]/gi, '-')
3168
+ .toLowerCase()
3169
+ .slice(0, 40)
2842
3170
  : 'term';
2843
3171
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2844
- const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'term');
3172
+ const { runSlot } = await import('../runs.js');
3173
+ const defaultDir = runSlot({
3174
+ baseDir: findProjectRoot(),
3175
+ app: getArg('--app'),
3176
+ run: getArg('--run'),
3177
+ kind: 'term',
3178
+ });
2845
3179
  const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
2846
3180
  ensureDir(output);
2847
3181
  console.log(`[Sweetlink] Recording terminal: ${command}`);
@@ -2940,8 +3274,20 @@ async function handleStatusCommand() {
2940
3274
  console.log('\nAction sequences are identical.');
2941
3275
  }
2942
3276
  result = {
2943
- a: { id: a.sessionId, label: a.label, duration: a.duration, actions: a.actionCount, errors: aErr },
2944
- b: { id: b.sessionId, label: b.label, duration: b.duration, actions: b.actionCount, errors: bErr },
3277
+ a: {
3278
+ id: a.sessionId,
3279
+ label: a.label,
3280
+ duration: a.duration,
3281
+ actions: a.actionCount,
3282
+ errors: aErr,
3283
+ },
3284
+ b: {
3285
+ id: b.sessionId,
3286
+ label: b.label,
3287
+ duration: b.duration,
3288
+ actions: b.actionCount,
3289
+ errors: bErr,
3290
+ },
2945
3291
  added,
2946
3292
  removed,
2947
3293
  };
@@ -2950,12 +3296,12 @@ async function handleStatusCommand() {
2950
3296
  // Open the index.html in the browser
2951
3297
  if (data.indexPath) {
2952
3298
  const { execFile } = await import('child_process');
2953
- const cmd = process.platform === 'darwin' ? 'open'
2954
- : process.platform === 'win32' ? 'cmd'
3299
+ const cmd = process.platform === 'darwin'
3300
+ ? 'open'
3301
+ : process.platform === 'win32'
3302
+ ? 'cmd'
2955
3303
  : 'xdg-open';
2956
- const cmdArgs = process.platform === 'win32'
2957
- ? ['/c', 'start', '', data.indexPath]
2958
- : [data.indexPath];
3304
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', data.indexPath] : [data.indexPath];
2959
3305
  execFile(cmd, cmdArgs, () => { });
2960
3306
  console.log(`[Sweetlink] Opened ${data.indexPath}`);
2961
3307
  }
@@ -3063,10 +3409,13 @@ async function handleStatusCommand() {
3063
3409
  console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
3064
3410
  console.log(` File: ${state.filePath}`);
3065
3411
  for (const s of state.sections) {
3066
- const preview = s.type === 'note' ? s.content.substring(0, 60) :
3067
- s.type === 'exec' ? `$ ${s.command}` :
3068
- s.type === 'screenshot' ? `[image] ${s.screenshotFile}` :
3069
- '[snapshot]';
3412
+ const preview = s.type === 'note'
3413
+ ? s.content.substring(0, 60)
3414
+ : s.type === 'exec'
3415
+ ? `$ ${s.command}`
3416
+ : s.type === 'screenshot'
3417
+ ? `[image] ${s.screenshotFile}`
3418
+ : '[snapshot]';
3070
3419
  console.log(` ${s.type.padEnd(12)} ${preview}`);
3071
3420
  }
3072
3421
  result = state;
@@ -3187,8 +3536,10 @@ async function handleStatusCommand() {
3187
3536
  const msg = error instanceof Error ? error.message : String(error);
3188
3537
  emitJson({
3189
3538
  ok: false,
3539
+ // commandType is non-undefined here: the early-exit at the top of
3540
+ // CLI dispatch handles the bare `--json` (batch mode) case.
3190
3541
  command: commandType,
3191
- data: null,
3542
+ data: getErrorData(error),
3192
3543
  error: msg,
3193
3544
  duration: Date.now() - startTime,
3194
3545
  });
@@ -3198,6 +3549,7 @@ async function handleStatusCommand() {
3198
3549
  // to end users and clutters the output. Set SWEETLINK_DEBUG=1 to see it.
3199
3550
  if (error instanceof Error) {
3200
3551
  console.error(`[Sweetlink] ${error.message}`);
3552
+ printErrorContext(error);
3201
3553
  if (process.env.SWEETLINK_DEBUG === '1' && error.stack) {
3202
3554
  console.error(error.stack);
3203
3555
  }