@ytspar/sweetlink 1.18.0 → 1.20.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 (78) 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 +537 -36
  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 +5 -5
  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 +7 -5
  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 +34 -11
  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.map +1 -1
  34. package/dist/daemon/server.d.ts.map +1 -1
  35. package/dist/daemon/server.js +419 -80
  36. package/dist/daemon/server.js.map +1 -1
  37. package/dist/daemon/summary.d.ts +1 -1
  38. package/dist/daemon/summary.d.ts.map +1 -1
  39. package/dist/daemon/types.d.ts +1 -1
  40. package/dist/daemon/types.d.ts.map +1 -1
  41. package/dist/daemon/types.js.map +1 -1
  42. package/dist/daemon/viewer.d.ts +1 -1
  43. package/dist/daemon/viewer.d.ts.map +1 -1
  44. package/dist/daemon/viewer.js +18 -10
  45. package/dist/daemon/viewer.js.map +1 -1
  46. package/dist/ruler.js +3 -1
  47. package/dist/ruler.js.map +1 -1
  48. package/dist/runs.d.ts +34 -0
  49. package/dist/runs.d.ts.map +1 -0
  50. package/dist/runs.js +61 -0
  51. package/dist/runs.js.map +1 -0
  52. package/dist/server/index.d.ts.map +1 -1
  53. package/dist/server/index.js +20 -10
  54. package/dist/server/index.js.map +1 -1
  55. package/dist/simulator/android.d.ts +35 -0
  56. package/dist/simulator/android.d.ts.map +1 -0
  57. package/dist/simulator/android.js +127 -0
  58. package/dist/simulator/android.js.map +1 -0
  59. package/dist/simulator/ios.d.ts +39 -0
  60. package/dist/simulator/ios.d.ts.map +1 -0
  61. package/dist/simulator/ios.js +123 -0
  62. package/dist/simulator/ios.js.map +1 -0
  63. package/dist/term/ansi.d.ts +37 -0
  64. package/dist/term/ansi.d.ts.map +1 -0
  65. package/dist/term/ansi.js +205 -0
  66. package/dist/term/ansi.js.map +1 -0
  67. package/dist/term/player.d.ts +25 -0
  68. package/dist/term/player.d.ts.map +1 -0
  69. package/dist/term/player.js +243 -0
  70. package/dist/term/player.js.map +1 -0
  71. package/dist/term/recorder.d.ts +33 -0
  72. package/dist/term/recorder.d.ts.map +1 -0
  73. package/dist/term/recorder.js +77 -0
  74. package/dist/term/recorder.js.map +1 -0
  75. package/dist/vite.d.ts.map +1 -1
  76. package/dist/vite.js +8 -4
  77. package/dist/vite.js.map +1 -1
  78. 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
 
@@ -1875,6 +1924,45 @@ const COMMAND_HELP = {
1875
1924
  pnpm sweetlink snapshot -i
1876
1925
  pnpm sweetlink click @e3
1877
1926
  pnpm sweetlink record stop`,
1927
+ sim: ` sim <ios|android> <command...>
1928
+ Record iOS Simulator or Android Emulator screen while running a command.
1929
+ Wraps \`xcrun simctl io booted recordVideo\` (iOS) or
1930
+ \`adb shell screenrecord\` (Android), writing an .mp4 of what was on
1931
+ screen during your XCUITest / Espresso / fastlane / appium run.
1932
+
1933
+ Options:
1934
+ --output <path> .mp4 path (default: .sweetlink/sim/<label>-<stamp>.mp4)
1935
+ --label <text> Embedded in filename
1936
+ --device <name|udid> Pick a specific simulator/emulator (default: first booted)
1937
+ --time-limit <sec> Android only — caps screen recording (max 180)
1938
+ --ignore-exit Don't propagate the recorded command's exit code
1939
+
1940
+ Requirements:
1941
+ iOS: Xcode + a booted Simulator (Simulator.app)
1942
+ Android: Android Platform Tools (\`adb\`) + a running emulator
1943
+
1944
+ Examples:
1945
+ pnpm sweetlink sim ios "fastlane scan" --device "iPhone 15"
1946
+ pnpm sweetlink sim android "./gradlew connectedAndroidTest"`,
1947
+ term: ` term <command...>
1948
+ Record a shell command's stdout/stderr into asciicast v2 + a self-contained
1949
+ HTML player. Captures real timing; the player has play/pause, 0.1×–4×
1950
+ speed, seek bar, and ANSI colour rendering.
1951
+
1952
+ Options:
1953
+ --output <path> .cast file path (default: .sweetlink/term/<label>-<stamp>.cast)
1954
+ --label <text> Label embedded in the .cast title + filename
1955
+ --app <name> Group artifacts under .sweetlink/<app>/<YYYYMMDD>/<run>/term/
1956
+ --run <id> Override the auto-generated run id (HHMM-SS or $SWEETLINK_RUN)
1957
+ --shell <path> Shell to invoke the command in (default: /bin/sh)
1958
+ --cols <n> Reported terminal width (default: 120)
1959
+ --rows <n> Reported terminal height (default: 30)
1960
+ --ignore-exit Don't propagate the recorded command's exit code
1961
+
1962
+ Examples:
1963
+ pnpm sweetlink term "pytest -v" --label api-tests
1964
+ pnpm sweetlink term "go test ./..." --app my-app --label go-tests
1965
+ pnpm sweetlink term "make build" --output .sweetlink/term/build.cast`,
1878
1966
  sessions: ` sessions [list|open]
1879
1967
  List or open all recorded sessions in this project.
1880
1968
 
@@ -1933,6 +2021,7 @@ const COMMAND_HELP = {
1933
2021
  const COMMAND_ALIASES = {
1934
2022
  measure: 'ruler',
1935
2023
  accessibility: 'a11y',
2024
+ context: 'inspect',
1936
2025
  };
1937
2026
  const GLOBAL_HELP = `
1938
2027
  Global Flags:
@@ -1973,9 +2062,12 @@ Commands:`);
1973
2062
  // Each block looks like:
1974
2063
  // " command [args]\n First-line description.\n..."
1975
2064
  // We pick the first non-empty line after the signature.
1976
- const lines = help.split('\n').map((l) => l.trim()).filter(Boolean);
2065
+ const lines = help
2066
+ .split('\n')
2067
+ .map((l) => l.trim())
2068
+ .filter(Boolean);
1977
2069
  const desc = lines[1] ?? '';
1978
- const summary = desc.length > 70 ? desc.slice(0, 67) + '…' : desc;
2070
+ const summary = desc.length > 70 ? `${desc.slice(0, 67)}…` : desc;
1979
2071
  console.log(` ${name.padEnd(14)} ${summary}`);
1980
2072
  }
1981
2073
  if (process.argv.includes('--all')) {
@@ -2002,12 +2094,161 @@ function showCommandHelp(command) {
2002
2094
  }
2003
2095
  // CLI argument parsing
2004
2096
  const args = process.argv.slice(2);
2005
- // Skip global flags to find the actual command
2006
- const commandType = args.find((a) => !a.startsWith('--')) || args[0];
2097
+ // Skip global flags to find the actual command. Falling back to args[0]
2098
+ // would surface flags like `--json` as the command — we want undefined
2099
+ // in that case so the no-command paths (help / batch mode) can fire.
2100
+ const commandType = args.find((a) => !a.startsWith('--'));
2007
2101
  if (!commandType || commandType === '--help' || commandType === '-h') {
2102
+ // `sweetlink --json` with stdin → multi-capture batch mode.
2103
+ // Reads { action: "capture", captures: [...] } from stdin and runs
2104
+ // each entry, aggregating results into a single JSON envelope on stdout.
2105
+ if (args.includes('--json') && !process.stdin.isTTY) {
2106
+ // Top-level await keeps the rest of the dispatch from running while
2107
+ // the batch is in flight; we exit before fallthrough either way.
2108
+ try {
2109
+ await runBatchFromStdin();
2110
+ process.exit(0);
2111
+ }
2112
+ catch (err) {
2113
+ const msg = err instanceof Error ? err.message : String(err);
2114
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
2115
+ process.exit(1);
2116
+ }
2117
+ }
2008
2118
  showHelp();
2009
2119
  process.exit(0);
2010
2120
  }
2121
+ async function runBatchFromStdin() {
2122
+ const chunks = [];
2123
+ for await (const chunk of process.stdin)
2124
+ chunks.push(chunk);
2125
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
2126
+ if (!raw) {
2127
+ throw new Error('No input on stdin. Pipe a JSON document with {"action":"capture","captures":[...]}.');
2128
+ }
2129
+ let body;
2130
+ try {
2131
+ body = JSON.parse(raw);
2132
+ }
2133
+ catch (e) {
2134
+ throw new Error(`Could not parse stdin as JSON: ${e instanceof Error ? e.message : e}`);
2135
+ }
2136
+ if (body.action !== 'capture' || !Array.isArray(body.captures)) {
2137
+ throw new Error('Expected { "action": "capture", "captures": [...] }.');
2138
+ }
2139
+ const startTime = Date.now();
2140
+ const results = [];
2141
+ for (const cap of body.captures) {
2142
+ const t0 = Date.now();
2143
+ const mode = cap.mode;
2144
+ const label = cap.label;
2145
+ try {
2146
+ const data = await runOneBatchCapture(cap);
2147
+ results.push({ ok: true, mode, label, data, duration: Date.now() - t0 });
2148
+ }
2149
+ catch (err) {
2150
+ results.push({
2151
+ ok: false,
2152
+ mode,
2153
+ label,
2154
+ error: err instanceof Error ? err.message : String(err),
2155
+ duration: Date.now() - t0,
2156
+ });
2157
+ }
2158
+ }
2159
+ const allOk = results.every((r) => r.ok);
2160
+ process.stdout.write(JSON.stringify({
2161
+ ok: allOk,
2162
+ duration: Date.now() - startTime,
2163
+ captures: results,
2164
+ }) + '\n');
2165
+ if (!allOk)
2166
+ process.exit(1);
2167
+ }
2168
+ async function runOneBatchCapture(cap) {
2169
+ const mode = cap.mode;
2170
+ if (!mode)
2171
+ throw new Error('Capture entry missing required "mode" field.');
2172
+ if (mode === 'term') {
2173
+ const command = cap.command;
2174
+ if (!command)
2175
+ throw new Error('term capture missing "command".');
2176
+ const label = cap.label ?? 'batch';
2177
+ const labelSlug = label
2178
+ .replace(/[^a-z0-9]/gi, '-')
2179
+ .toLowerCase()
2180
+ .slice(0, 40);
2181
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2182
+ const output = cap.output ??
2183
+ path.join(findProjectRoot(), '.sweetlink', 'term', `${labelSlug}-${stamp}.cast`);
2184
+ ensureDir(output);
2185
+ const { captureTerminal } = await import('../term/recorder.js');
2186
+ const { generatePlayer } = await import('../term/player.js');
2187
+ const result = await captureTerminal({
2188
+ command,
2189
+ output,
2190
+ label,
2191
+ shell: cap.shell,
2192
+ cols: typeof cap.cols === 'number' ? cap.cols : undefined,
2193
+ rows: typeof cap.rows === 'number' ? cap.rows : undefined,
2194
+ });
2195
+ const playerPath = await generatePlayer({ castPath: output });
2196
+ return {
2197
+ castPath: output,
2198
+ playerPath,
2199
+ durationSec: result.durationSec,
2200
+ exitCode: result.exitCode,
2201
+ events: result.events,
2202
+ };
2203
+ }
2204
+ if (mode === 'sim-ios' || mode === 'sim-android') {
2205
+ const command = cap.command;
2206
+ if (!command)
2207
+ throw new Error(`${mode} capture missing "command".`);
2208
+ const label = cap.label ?? 'batch';
2209
+ const labelSlug = label
2210
+ .replace(/[^a-z0-9]/gi, '-')
2211
+ .toLowerCase()
2212
+ .slice(0, 40);
2213
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2214
+ const output = cap.output ??
2215
+ path.join(findProjectRoot(), '.sweetlink', 'sim', `${labelSlug}-${stamp}.mp4`);
2216
+ ensureDir(output);
2217
+ const device = cap.device;
2218
+ if (mode === 'sim-ios') {
2219
+ const { recordIosSimulator } = await import('../simulator/ios.js');
2220
+ return recordIosSimulator({ command, output, device });
2221
+ }
2222
+ const { recordAndroidEmulator } = await import('../simulator/android.js');
2223
+ return recordAndroidEmulator({
2224
+ command,
2225
+ output,
2226
+ device,
2227
+ timeLimit: typeof cap.timeLimit === 'number' ? cap.timeLimit : undefined,
2228
+ });
2229
+ }
2230
+ if (mode === 'screenshot') {
2231
+ const targetUrl = cap.url ?? 'http://localhost:3000';
2232
+ const projRoot = findProjectRoot();
2233
+ const state = await ensureDaemon(projRoot, targetUrl);
2234
+ const resp = await daemonRequest(state, 'screenshot', {
2235
+ selector: cap.selector,
2236
+ fullPage: cap.fullPage,
2237
+ viewport: cap.viewport,
2238
+ padding: cap.padding,
2239
+ theme: cap.theme,
2240
+ });
2241
+ const data = resp.data;
2242
+ if (cap.output) {
2243
+ const outputPath = cap.output;
2244
+ ensureDir(outputPath);
2245
+ fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
2246
+ return { path: outputPath, width: data.width, height: data.height };
2247
+ }
2248
+ return { width: data.width, height: data.height, base64Length: data.screenshot.length };
2249
+ }
2250
+ throw new Error(`Unknown capture mode: ${mode}. Allowed: term, sim-ios, sim-android, screenshot.`);
2251
+ }
2011
2252
  // Helper function to get argument value
2012
2253
  function getArg(flag) {
2013
2254
  const index = args.indexOf(flag);
@@ -2017,6 +2258,7 @@ function hasFlag(flag) {
2017
2258
  return args.includes(flag);
2018
2259
  }
2019
2260
  // Per-command --help: `pnpm sweetlink screenshot --help`
2261
+ // Past the early-exit at the top of dispatch, commandType is non-null.
2020
2262
  if (hasFlag('--help') || hasFlag('-h')) {
2021
2263
  showCommandHelp(commandType);
2022
2264
  process.exit(0);
@@ -2026,6 +2268,8 @@ if (hasFlag('--output-schema')) {
2026
2268
  // If commandType is a known command, print just that schema; otherwise print all
2027
2269
  const knownCommands = [
2028
2270
  'screenshot',
2271
+ 'inspect',
2272
+ 'context',
2029
2273
  'query',
2030
2274
  'logs',
2031
2275
  'exec',
@@ -2047,6 +2291,8 @@ if (hasFlag('--output-schema')) {
2047
2291
  'fill',
2048
2292
  'console',
2049
2293
  'record',
2294
+ 'term',
2295
+ 'sim',
2050
2296
  'sessions',
2051
2297
  'proof',
2052
2298
  'report',
@@ -2057,7 +2303,9 @@ if (hasFlag('--output-schema')) {
2057
2303
  ? 'ruler'
2058
2304
  : commandType === 'accessibility'
2059
2305
  ? 'a11y'
2060
- : commandType
2306
+ : commandType === 'context'
2307
+ ? 'inspect'
2308
+ : commandType
2061
2309
  : undefined;
2062
2310
  printOutputSchema(schemaCommand);
2063
2311
  process.exit(0);
@@ -2091,6 +2339,24 @@ function setupJsonMode(command, startTime) {
2091
2339
  });
2092
2340
  return { origExit, getLastError: () => lastErrorMsg };
2093
2341
  }
2342
+ function getErrorData(error) {
2343
+ if (error instanceof DaemonRequestError) {
2344
+ return {
2345
+ action: error.action,
2346
+ status: error.status,
2347
+ ...(error.data ? error.data : {}),
2348
+ };
2349
+ }
2350
+ return null;
2351
+ }
2352
+ function printErrorContext(error) {
2353
+ if (!(error instanceof DaemonRequestError) || !error.data)
2354
+ return;
2355
+ const failureScreenshot = error.data.failureScreenshot;
2356
+ if (typeof failureScreenshot === 'string') {
2357
+ console.error(`[Sweetlink] Failure screenshot: ${failureScreenshot}`);
2358
+ }
2359
+ }
2094
2360
  /**
2095
2361
  * Handle the `wait` command: wait for a server to be ready.
2096
2362
  */
@@ -2141,9 +2407,14 @@ async function handleStatusCommand() {
2141
2407
  }
2142
2408
  (async () => {
2143
2409
  const startTime = Date.now();
2144
- // Resolve --app flag: discover the matching Sweetlink server by branch/app name
2410
+ // Resolve --app flag: for WS-bridge commands, this discovers the matching
2411
+ // Sweetlink server by branch/app name. For commands that produce artifacts
2412
+ // (term/sim), --app is a *namespace* used in the artifact directory layout
2413
+ // (.sweetlink/<app>/<YYYYMMDD>/<run>/...) — those handlers read --app
2414
+ // themselves, so we skip discovery here.
2145
2415
  const appTarget = getArg('--app');
2146
- if (appTarget) {
2416
+ const isArtifactCommand = commandType === 'term' || commandType === 'sim';
2417
+ if (appTarget && !isArtifactCommand) {
2147
2418
  try {
2148
2419
  resolvedWsUrl = await discoverServer(appTarget);
2149
2420
  console.log(`[Sweetlink] Targeting server: ${resolvedWsUrl}`);
@@ -2184,6 +2455,42 @@ async function handleStatusCommand() {
2184
2455
  : undefined,
2185
2456
  });
2186
2457
  break;
2458
+ case 'inspect':
2459
+ case 'context': {
2460
+ const projRoot = findProjectRoot();
2461
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2462
+ const state = await ensureDaemon(projRoot, targetUrl);
2463
+ const actionTranscript = [];
2464
+ args.forEach((arg, index) => {
2465
+ if (arg !== '--action')
2466
+ return;
2467
+ const value = args[index + 1];
2468
+ if (!value)
2469
+ return;
2470
+ actionTranscript.push({ action: value });
2471
+ });
2472
+ const resp = await daemonRequest(state, 'inspect', {
2473
+ last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
2474
+ label: getArg('--label'),
2475
+ expectedOutcome: getArg('--expected'),
2476
+ actionTranscript,
2477
+ includeA11y: !hasFlag('--no-a11y'),
2478
+ });
2479
+ const data = resp.data;
2480
+ const output = getArg('--output');
2481
+ if (output) {
2482
+ ensureDir(output);
2483
+ fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
2484
+ }
2485
+ if (getArg('--format') === 'json') {
2486
+ console.log(JSON.stringify(data, null, 2));
2487
+ }
2488
+ else {
2489
+ printInspectSummary(data);
2490
+ }
2491
+ result = data;
2492
+ break;
2493
+ }
2187
2494
  case 'query': {
2188
2495
  const selector = getArg('--selector');
2189
2496
  if (!selector) {
@@ -2279,7 +2586,9 @@ async function handleStatusCommand() {
2279
2586
  }
2280
2587
  }
2281
2588
  }
2282
- catch { /* fall through to WS path */ }
2589
+ catch {
2590
+ /* fall through to WS path */
2591
+ }
2283
2592
  result = await click({
2284
2593
  selector: clickTarget,
2285
2594
  text: clickText,
@@ -2407,7 +2716,8 @@ async function handleStatusCommand() {
2407
2716
  }
2408
2717
  const sessionDirArg = getArg('--session') ?? '.sweetlink';
2409
2718
  // Find latest session manifest
2410
- const sessionFiles = fs.readdirSync(sessionDirArg)
2719
+ const sessionFiles = fs
2720
+ .readdirSync(sessionDirArg)
2411
2721
  .filter((f) => f.startsWith('session-'))
2412
2722
  .sort()
2413
2723
  .reverse();
@@ -2462,16 +2772,19 @@ async function handleStatusCommand() {
2462
2772
  const data = resp.data;
2463
2773
  const m = data.manifest;
2464
2774
  console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
2465
- console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ' | Video: ' + m.video : ''}`);
2775
+ console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
2466
2776
  // Auto-open the viewer (cross-platform; --no-open to suppress)
2467
2777
  if (data.viewerPath && !hasFlag('--no-open')) {
2468
2778
  console.log(` Viewer: ${data.viewerPath}`);
2469
2779
  const { execFile } = await import('child_process');
2470
2780
  // `start` on Windows is a cmd builtin, not an exe — must invoke via cmd.
2471
- const cmd = process.platform === 'darwin' ? 'open'
2472
- : process.platform === 'win32' ? 'cmd'
2781
+ const cmd = process.platform === 'darwin'
2782
+ ? 'open'
2783
+ : process.platform === 'win32'
2784
+ ? 'cmd'
2473
2785
  : 'xdg-open';
2474
- const args = process.platform === 'win32' ? ['/c', 'start', '', data.viewerPath]
2786
+ const args = process.platform === 'win32'
2787
+ ? ['/c', 'start', '', data.viewerPath]
2475
2788
  : [data.viewerPath];
2476
2789
  execFile(cmd, args, (err) => {
2477
2790
  if (err)
@@ -2514,7 +2827,10 @@ async function handleStatusCommand() {
2514
2827
  const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
2515
2828
  const startData = startResp.data;
2516
2829
  console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
2517
- const steps = script.split(';').map((s) => s.trim()).filter(Boolean);
2830
+ const steps = script
2831
+ .split(';')
2832
+ .map((s) => s.trim())
2833
+ .filter(Boolean);
2518
2834
  // Snapshot once up-front so refs resolve.
2519
2835
  await daemonRequest(state, 'snapshot', { interactive: true });
2520
2836
  for (const step of steps) {
@@ -2598,7 +2914,8 @@ async function handleStatusCommand() {
2598
2914
  console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
2599
2915
  process.exit(1);
2600
2916
  }
2601
- const reportSessions = fs.readdirSync(sessionDirArg)
2917
+ const reportSessions = fs
2918
+ .readdirSync(sessionDirArg)
2602
2919
  .filter((f) => f.startsWith('session-'))
2603
2920
  .sort()
2604
2921
  .reverse();
@@ -2675,7 +2992,10 @@ async function handleStatusCommand() {
2675
2992
  }
2676
2993
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
2677
2994
  const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
2678
- const payload = { summary, manifest };
2995
+ const payload = {
2996
+ summary,
2997
+ manifest,
2998
+ };
2679
2999
  // Include viewer HTML for Slack/Discord webhooks
2680
3000
  if (/slack|discord/i.test(webhookUrl)) {
2681
3001
  const viewerPath = path.join(reportSessionDir, 'viewer.html');
@@ -2711,6 +3031,169 @@ async function handleStatusCommand() {
2711
3031
  }
2712
3032
  break;
2713
3033
  }
3034
+ case 'sim': {
3035
+ // Record iOS Simulator or Android Emulator screen while running a command.
3036
+ // Example: sweetlink sim ios "fastlane scan" --device "iPhone 15"
3037
+ const platform = args[1];
3038
+ if (platform !== 'ios' && platform !== 'android') {
3039
+ console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
3040
+ process.exit(1);
3041
+ }
3042
+ const flagsWithValues = new Set([
3043
+ '--output',
3044
+ '--label',
3045
+ '--device',
3046
+ '--time-limit',
3047
+ '--app',
3048
+ '--run',
3049
+ ]);
3050
+ const positional = [];
3051
+ for (let i = 2; i < args.length; i++) {
3052
+ const a = args[i];
3053
+ if (a.startsWith('--')) {
3054
+ if (flagsWithValues.has(a))
3055
+ i++;
3056
+ continue;
3057
+ }
3058
+ positional.push(a);
3059
+ }
3060
+ const command = positional.join(' ').trim();
3061
+ if (!command) {
3062
+ console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
3063
+ process.exit(1);
3064
+ }
3065
+ const label = getArg('--label');
3066
+ const labelSlug = label
3067
+ ? label
3068
+ .replace(/[^a-z0-9]/gi, '-')
3069
+ .toLowerCase()
3070
+ .slice(0, 40)
3071
+ : `sim-${platform}`;
3072
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3073
+ const { runSlot: simRunSlot } = await import('../runs.js');
3074
+ const defaultDir = simRunSlot({
3075
+ baseDir: findProjectRoot(),
3076
+ app: getArg('--app'),
3077
+ run: getArg('--run'),
3078
+ kind: 'sim',
3079
+ });
3080
+ const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
3081
+ ensureDir(output);
3082
+ const device = getArg('--device');
3083
+ console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
3084
+ let recResult;
3085
+ if (platform === 'ios') {
3086
+ const { recordIosSimulator } = await import('../simulator/ios.js');
3087
+ recResult = await recordIosSimulator({ command, output, device });
3088
+ }
3089
+ else {
3090
+ const { recordAndroidEmulator } = await import('../simulator/android.js');
3091
+ const tl = getArg('--time-limit');
3092
+ recResult = await recordAndroidEmulator({
3093
+ command,
3094
+ output,
3095
+ device,
3096
+ timeLimit: tl ? parseInt(tl, 10) : undefined,
3097
+ });
3098
+ }
3099
+ let sizeKb = '?';
3100
+ try {
3101
+ sizeKb = String(Math.round(fs.statSync(output).size / 1024));
3102
+ }
3103
+ catch {
3104
+ /* file may not exist if recordingClosed is false */
3105
+ }
3106
+ console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
3107
+ `${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
3108
+ (recResult.recordingClosed
3109
+ ? ''
3110
+ : ' (recording was force-killed; mp4 may be incomplete)'));
3111
+ result = {
3112
+ path: output,
3113
+ device: recResult.device,
3114
+ durationSec: recResult.durationSec,
3115
+ exitCode: recResult.exitCode,
3116
+ recordingClosed: recResult.recordingClosed,
3117
+ };
3118
+ if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3119
+ process.exit(recResult.exitCode);
3120
+ }
3121
+ break;
3122
+ }
3123
+ case 'term': {
3124
+ // Record a shell command's stdout/stderr into asciicast v2 + HTML player.
3125
+ // Example: sweetlink term "pytest -v" --label api-tests --app my-app
3126
+ const flagsWithValues = new Set([
3127
+ '--output',
3128
+ '--label',
3129
+ '--shell',
3130
+ '--cols',
3131
+ '--rows',
3132
+ '--app',
3133
+ '--run',
3134
+ ]);
3135
+ const positional = [];
3136
+ for (let i = 1; i < args.length; i++) {
3137
+ const a = args[i];
3138
+ if (a.startsWith('--')) {
3139
+ if (flagsWithValues.has(a))
3140
+ i++;
3141
+ continue;
3142
+ }
3143
+ positional.push(a);
3144
+ }
3145
+ const command = positional.join(' ').trim();
3146
+ if (!command) {
3147
+ console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
3148
+ process.exit(1);
3149
+ }
3150
+ const label = getArg('--label');
3151
+ const labelSlug = label
3152
+ ? label
3153
+ .replace(/[^a-z0-9]/gi, '-')
3154
+ .toLowerCase()
3155
+ .slice(0, 40)
3156
+ : 'term';
3157
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3158
+ const { runSlot } = await import('../runs.js');
3159
+ const defaultDir = runSlot({
3160
+ baseDir: findProjectRoot(),
3161
+ app: getArg('--app'),
3162
+ run: getArg('--run'),
3163
+ kind: 'term',
3164
+ });
3165
+ const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
3166
+ ensureDir(output);
3167
+ console.log(`[Sweetlink] Recording terminal: ${command}`);
3168
+ const { captureTerminal } = await import('../term/recorder.js');
3169
+ const { generatePlayer } = await import('../term/player.js');
3170
+ const cap = await captureTerminal({
3171
+ command,
3172
+ output,
3173
+ label,
3174
+ shell: getArg('--shell'),
3175
+ cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
3176
+ rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
3177
+ });
3178
+ const playerPath = await generatePlayer({ castPath: output });
3179
+ console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
3180
+ `${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
3181
+ console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
3182
+ result = {
3183
+ castPath: output,
3184
+ playerPath,
3185
+ durationSec: cap.durationSec,
3186
+ bytes: cap.bytes,
3187
+ events: cap.events,
3188
+ exitCode: cap.exitCode,
3189
+ };
3190
+ // Propagate the recorded command's exit code by default so CI fails
3191
+ // when the wrapped tests fail.
3192
+ if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3193
+ process.exit(cap.exitCode);
3194
+ }
3195
+ break;
3196
+ }
2714
3197
  case 'sessions': {
2715
3198
  const sub = args[1];
2716
3199
  const projRoot = findProjectRoot();
@@ -2777,8 +3260,20 @@ async function handleStatusCommand() {
2777
3260
  console.log('\nAction sequences are identical.');
2778
3261
  }
2779
3262
  result = {
2780
- a: { id: a.sessionId, label: a.label, duration: a.duration, actions: a.actionCount, errors: aErr },
2781
- b: { id: b.sessionId, label: b.label, duration: b.duration, actions: b.actionCount, errors: bErr },
3263
+ a: {
3264
+ id: a.sessionId,
3265
+ label: a.label,
3266
+ duration: a.duration,
3267
+ actions: a.actionCount,
3268
+ errors: aErr,
3269
+ },
3270
+ b: {
3271
+ id: b.sessionId,
3272
+ label: b.label,
3273
+ duration: b.duration,
3274
+ actions: b.actionCount,
3275
+ errors: bErr,
3276
+ },
2782
3277
  added,
2783
3278
  removed,
2784
3279
  };
@@ -2787,12 +3282,12 @@ async function handleStatusCommand() {
2787
3282
  // Open the index.html in the browser
2788
3283
  if (data.indexPath) {
2789
3284
  const { execFile } = await import('child_process');
2790
- const cmd = process.platform === 'darwin' ? 'open'
2791
- : process.platform === 'win32' ? 'cmd'
3285
+ const cmd = process.platform === 'darwin'
3286
+ ? 'open'
3287
+ : process.platform === 'win32'
3288
+ ? 'cmd'
2792
3289
  : 'xdg-open';
2793
- const cmdArgs = process.platform === 'win32'
2794
- ? ['/c', 'start', '', data.indexPath]
2795
- : [data.indexPath];
3290
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', data.indexPath] : [data.indexPath];
2796
3291
  execFile(cmd, cmdArgs, () => { });
2797
3292
  console.log(`[Sweetlink] Opened ${data.indexPath}`);
2798
3293
  }
@@ -2900,10 +3395,13 @@ async function handleStatusCommand() {
2900
3395
  console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
2901
3396
  console.log(` File: ${state.filePath}`);
2902
3397
  for (const s of state.sections) {
2903
- const preview = s.type === 'note' ? s.content.substring(0, 60) :
2904
- s.type === 'exec' ? `$ ${s.command}` :
2905
- s.type === 'screenshot' ? `[image] ${s.screenshotFile}` :
2906
- '[snapshot]';
3398
+ const preview = s.type === 'note'
3399
+ ? s.content.substring(0, 60)
3400
+ : s.type === 'exec'
3401
+ ? `$ ${s.command}`
3402
+ : s.type === 'screenshot'
3403
+ ? `[image] ${s.screenshotFile}`
3404
+ : '[snapshot]';
2907
3405
  console.log(` ${s.type.padEnd(12)} ${preview}`);
2908
3406
  }
2909
3407
  result = state;
@@ -3024,8 +3522,10 @@ async function handleStatusCommand() {
3024
3522
  const msg = error instanceof Error ? error.message : String(error);
3025
3523
  emitJson({
3026
3524
  ok: false,
3525
+ // commandType is non-undefined here: the early-exit at the top of
3526
+ // CLI dispatch handles the bare `--json` (batch mode) case.
3027
3527
  command: commandType,
3028
- data: null,
3528
+ data: getErrorData(error),
3029
3529
  error: msg,
3030
3530
  duration: Date.now() - startTime,
3031
3531
  });
@@ -3035,6 +3535,7 @@ async function handleStatusCommand() {
3035
3535
  // to end users and clutters the output. Set SWEETLINK_DEBUG=1 to see it.
3036
3536
  if (error instanceof Error) {
3037
3537
  console.error(`[Sweetlink] ${error.message}`);
3538
+ printErrorContext(error);
3038
3539
  if (process.env.SWEETLINK_DEBUG === '1' && error.stack) {
3039
3540
  console.error(error.stack);
3040
3541
  }