@ytspar/sweetlink 1.19.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 (70) 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 +385 -47
  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.map +1 -1
  56. package/dist/simulator/android.js +13 -5
  57. package/dist/simulator/android.js.map +1 -1
  58. package/dist/simulator/ios.d.ts.map +1 -1
  59. package/dist/simulator/ios.js +13 -5
  60. package/dist/simulator/ios.js.map +1 -1
  61. package/dist/term/ansi.d.ts.map +1 -1
  62. package/dist/term/ansi.js +49 -14
  63. package/dist/term/ansi.js.map +1 -1
  64. package/dist/term/player.d.ts.map +1 -1
  65. package/dist/term/player.js +5 -5
  66. package/dist/term/player.js.map +1 -1
  67. package/dist/vite.d.ts.map +1 -1
  68. package/dist/vite.js +8 -4
  69. package/dist/vite.js.map +1 -1
  70. 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
 
@@ -1903,6 +1952,8 @@ const COMMAND_HELP = {
1903
1952
  Options:
1904
1953
  --output <path> .cast file path (default: .sweetlink/term/<label>-<stamp>.cast)
1905
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)
1906
1957
  --shell <path> Shell to invoke the command in (default: /bin/sh)
1907
1958
  --cols <n> Reported terminal width (default: 120)
1908
1959
  --rows <n> Reported terminal height (default: 30)
@@ -1910,7 +1961,7 @@ const COMMAND_HELP = {
1910
1961
 
1911
1962
  Examples:
1912
1963
  pnpm sweetlink term "pytest -v" --label api-tests
1913
- pnpm sweetlink term "go test ./..." --label go-tests
1964
+ pnpm sweetlink term "go test ./..." --app my-app --label go-tests
1914
1965
  pnpm sweetlink term "make build" --output .sweetlink/term/build.cast`,
1915
1966
  sessions: ` sessions [list|open]
1916
1967
  List or open all recorded sessions in this project.
@@ -1970,6 +2021,7 @@ const COMMAND_HELP = {
1970
2021
  const COMMAND_ALIASES = {
1971
2022
  measure: 'ruler',
1972
2023
  accessibility: 'a11y',
2024
+ context: 'inspect',
1973
2025
  };
1974
2026
  const GLOBAL_HELP = `
1975
2027
  Global Flags:
@@ -2010,9 +2062,12 @@ Commands:`);
2010
2062
  // Each block looks like:
2011
2063
  // " command [args]\n First-line description.\n..."
2012
2064
  // We pick the first non-empty line after the signature.
2013
- 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);
2014
2069
  const desc = lines[1] ?? '';
2015
- const summary = desc.length > 70 ? desc.slice(0, 67) + '…' : desc;
2070
+ const summary = desc.length > 70 ? `${desc.slice(0, 67)}…` : desc;
2016
2071
  console.log(` ${name.padEnd(14)} ${summary}`);
2017
2072
  }
2018
2073
  if (process.argv.includes('--all')) {
@@ -2039,12 +2094,161 @@ function showCommandHelp(command) {
2039
2094
  }
2040
2095
  // CLI argument parsing
2041
2096
  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];
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('--'));
2044
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
+ }
2045
2118
  showHelp();
2046
2119
  process.exit(0);
2047
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
+ }
2048
2252
  // Helper function to get argument value
2049
2253
  function getArg(flag) {
2050
2254
  const index = args.indexOf(flag);
@@ -2054,6 +2258,7 @@ function hasFlag(flag) {
2054
2258
  return args.includes(flag);
2055
2259
  }
2056
2260
  // Per-command --help: `pnpm sweetlink screenshot --help`
2261
+ // Past the early-exit at the top of dispatch, commandType is non-null.
2057
2262
  if (hasFlag('--help') || hasFlag('-h')) {
2058
2263
  showCommandHelp(commandType);
2059
2264
  process.exit(0);
@@ -2063,6 +2268,8 @@ if (hasFlag('--output-schema')) {
2063
2268
  // If commandType is a known command, print just that schema; otherwise print all
2064
2269
  const knownCommands = [
2065
2270
  'screenshot',
2271
+ 'inspect',
2272
+ 'context',
2066
2273
  'query',
2067
2274
  'logs',
2068
2275
  'exec',
@@ -2096,7 +2303,9 @@ if (hasFlag('--output-schema')) {
2096
2303
  ? 'ruler'
2097
2304
  : commandType === 'accessibility'
2098
2305
  ? 'a11y'
2099
- : commandType
2306
+ : commandType === 'context'
2307
+ ? 'inspect'
2308
+ : commandType
2100
2309
  : undefined;
2101
2310
  printOutputSchema(schemaCommand);
2102
2311
  process.exit(0);
@@ -2130,6 +2339,24 @@ function setupJsonMode(command, startTime) {
2130
2339
  });
2131
2340
  return { origExit, getLastError: () => lastErrorMsg };
2132
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
+ }
2133
2360
  /**
2134
2361
  * Handle the `wait` command: wait for a server to be ready.
2135
2362
  */
@@ -2180,9 +2407,14 @@ async function handleStatusCommand() {
2180
2407
  }
2181
2408
  (async () => {
2182
2409
  const startTime = Date.now();
2183
- // 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.
2184
2415
  const appTarget = getArg('--app');
2185
- if (appTarget) {
2416
+ const isArtifactCommand = commandType === 'term' || commandType === 'sim';
2417
+ if (appTarget && !isArtifactCommand) {
2186
2418
  try {
2187
2419
  resolvedWsUrl = await discoverServer(appTarget);
2188
2420
  console.log(`[Sweetlink] Targeting server: ${resolvedWsUrl}`);
@@ -2223,6 +2455,42 @@ async function handleStatusCommand() {
2223
2455
  : undefined,
2224
2456
  });
2225
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
+ }
2226
2494
  case 'query': {
2227
2495
  const selector = getArg('--selector');
2228
2496
  if (!selector) {
@@ -2318,7 +2586,9 @@ async function handleStatusCommand() {
2318
2586
  }
2319
2587
  }
2320
2588
  }
2321
- catch { /* fall through to WS path */ }
2589
+ catch {
2590
+ /* fall through to WS path */
2591
+ }
2322
2592
  result = await click({
2323
2593
  selector: clickTarget,
2324
2594
  text: clickText,
@@ -2446,7 +2716,8 @@ async function handleStatusCommand() {
2446
2716
  }
2447
2717
  const sessionDirArg = getArg('--session') ?? '.sweetlink';
2448
2718
  // Find latest session manifest
2449
- const sessionFiles = fs.readdirSync(sessionDirArg)
2719
+ const sessionFiles = fs
2720
+ .readdirSync(sessionDirArg)
2450
2721
  .filter((f) => f.startsWith('session-'))
2451
2722
  .sort()
2452
2723
  .reverse();
@@ -2501,16 +2772,19 @@ async function handleStatusCommand() {
2501
2772
  const data = resp.data;
2502
2773
  const m = data.manifest;
2503
2774
  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 : ''}`);
2775
+ console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
2505
2776
  // Auto-open the viewer (cross-platform; --no-open to suppress)
2506
2777
  if (data.viewerPath && !hasFlag('--no-open')) {
2507
2778
  console.log(` Viewer: ${data.viewerPath}`);
2508
2779
  const { execFile } = await import('child_process');
2509
2780
  // `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'
2781
+ const cmd = process.platform === 'darwin'
2782
+ ? 'open'
2783
+ : process.platform === 'win32'
2784
+ ? 'cmd'
2512
2785
  : 'xdg-open';
2513
- const args = process.platform === 'win32' ? ['/c', 'start', '', data.viewerPath]
2786
+ const args = process.platform === 'win32'
2787
+ ? ['/c', 'start', '', data.viewerPath]
2514
2788
  : [data.viewerPath];
2515
2789
  execFile(cmd, args, (err) => {
2516
2790
  if (err)
@@ -2553,7 +2827,10 @@ async function handleStatusCommand() {
2553
2827
  const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
2554
2828
  const startData = startResp.data;
2555
2829
  console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
2556
- const steps = script.split(';').map((s) => s.trim()).filter(Boolean);
2830
+ const steps = script
2831
+ .split(';')
2832
+ .map((s) => s.trim())
2833
+ .filter(Boolean);
2557
2834
  // Snapshot once up-front so refs resolve.
2558
2835
  await daemonRequest(state, 'snapshot', { interactive: true });
2559
2836
  for (const step of steps) {
@@ -2637,7 +2914,8 @@ async function handleStatusCommand() {
2637
2914
  console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
2638
2915
  process.exit(1);
2639
2916
  }
2640
- const reportSessions = fs.readdirSync(sessionDirArg)
2917
+ const reportSessions = fs
2918
+ .readdirSync(sessionDirArg)
2641
2919
  .filter((f) => f.startsWith('session-'))
2642
2920
  .sort()
2643
2921
  .reverse();
@@ -2714,7 +2992,10 @@ async function handleStatusCommand() {
2714
2992
  }
2715
2993
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
2716
2994
  const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
2717
- const payload = { summary, manifest };
2995
+ const payload = {
2996
+ summary,
2997
+ manifest,
2998
+ };
2718
2999
  // Include viewer HTML for Slack/Discord webhooks
2719
3000
  if (/slack|discord/i.test(webhookUrl)) {
2720
3001
  const viewerPath = path.join(reportSessionDir, 'viewer.html');
@@ -2758,7 +3039,14 @@ async function handleStatusCommand() {
2758
3039
  console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
2759
3040
  process.exit(1);
2760
3041
  }
2761
- const flagsWithValues = new Set(['--output', '--label', '--device', '--time-limit']);
3042
+ const flagsWithValues = new Set([
3043
+ '--output',
3044
+ '--label',
3045
+ '--device',
3046
+ '--time-limit',
3047
+ '--app',
3048
+ '--run',
3049
+ ]);
2762
3050
  const positional = [];
2763
3051
  for (let i = 2; i < args.length; i++) {
2764
3052
  const a = args[i];
@@ -2776,10 +3064,19 @@ async function handleStatusCommand() {
2776
3064
  }
2777
3065
  const label = getArg('--label');
2778
3066
  const labelSlug = label
2779
- ? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
3067
+ ? label
3068
+ .replace(/[^a-z0-9]/gi, '-')
3069
+ .toLowerCase()
3070
+ .slice(0, 40)
2780
3071
  : `sim-${platform}`;
2781
3072
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2782
- const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'sim');
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
+ });
2783
3080
  const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
2784
3081
  ensureDir(output);
2785
3082
  const device = getArg('--device');
@@ -2793,7 +3090,9 @@ async function handleStatusCommand() {
2793
3090
  const { recordAndroidEmulator } = await import('../simulator/android.js');
2794
3091
  const tl = getArg('--time-limit');
2795
3092
  recResult = await recordAndroidEmulator({
2796
- command, output, device,
3093
+ command,
3094
+ output,
3095
+ device,
2797
3096
  timeLimit: tl ? parseInt(tl, 10) : undefined,
2798
3097
  });
2799
3098
  }
@@ -2801,10 +3100,14 @@ async function handleStatusCommand() {
2801
3100
  try {
2802
3101
  sizeKb = String(Math.round(fs.statSync(output).size / 1024));
2803
3102
  }
2804
- catch { /* file may not exist if recordingClosed is false */ }
3103
+ catch {
3104
+ /* file may not exist if recordingClosed is false */
3105
+ }
2805
3106
  console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
2806
3107
  `${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
2807
- (recResult.recordingClosed ? '' : ' (recording was force-killed; mp4 may be incomplete)'));
3108
+ (recResult.recordingClosed
3109
+ ? ''
3110
+ : ' (recording was force-killed; mp4 may be incomplete)'));
2808
3111
  result = {
2809
3112
  path: output,
2810
3113
  device: recResult.device,
@@ -2819,8 +3122,16 @@ async function handleStatusCommand() {
2819
3122
  }
2820
3123
  case 'term': {
2821
3124
  // 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']);
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
+ ]);
2824
3135
  const positional = [];
2825
3136
  for (let i = 1; i < args.length; i++) {
2826
3137
  const a = args[i];
@@ -2838,10 +3149,19 @@ async function handleStatusCommand() {
2838
3149
  }
2839
3150
  const label = getArg('--label');
2840
3151
  const labelSlug = label
2841
- ? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
3152
+ ? label
3153
+ .replace(/[^a-z0-9]/gi, '-')
3154
+ .toLowerCase()
3155
+ .slice(0, 40)
2842
3156
  : 'term';
2843
3157
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2844
- const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'term');
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
+ });
2845
3165
  const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
2846
3166
  ensureDir(output);
2847
3167
  console.log(`[Sweetlink] Recording terminal: ${command}`);
@@ -2940,8 +3260,20 @@ async function handleStatusCommand() {
2940
3260
  console.log('\nAction sequences are identical.');
2941
3261
  }
2942
3262
  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 },
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
+ },
2945
3277
  added,
2946
3278
  removed,
2947
3279
  };
@@ -2950,12 +3282,12 @@ async function handleStatusCommand() {
2950
3282
  // Open the index.html in the browser
2951
3283
  if (data.indexPath) {
2952
3284
  const { execFile } = await import('child_process');
2953
- const cmd = process.platform === 'darwin' ? 'open'
2954
- : process.platform === 'win32' ? 'cmd'
3285
+ const cmd = process.platform === 'darwin'
3286
+ ? 'open'
3287
+ : process.platform === 'win32'
3288
+ ? 'cmd'
2955
3289
  : 'xdg-open';
2956
- const cmdArgs = process.platform === 'win32'
2957
- ? ['/c', 'start', '', data.indexPath]
2958
- : [data.indexPath];
3290
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', data.indexPath] : [data.indexPath];
2959
3291
  execFile(cmd, cmdArgs, () => { });
2960
3292
  console.log(`[Sweetlink] Opened ${data.indexPath}`);
2961
3293
  }
@@ -3063,10 +3395,13 @@ async function handleStatusCommand() {
3063
3395
  console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
3064
3396
  console.log(` File: ${state.filePath}`);
3065
3397
  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]';
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]';
3070
3405
  console.log(` ${s.type.padEnd(12)} ${preview}`);
3071
3406
  }
3072
3407
  result = state;
@@ -3187,8 +3522,10 @@ async function handleStatusCommand() {
3187
3522
  const msg = error instanceof Error ? error.message : String(error);
3188
3523
  emitJson({
3189
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.
3190
3527
  command: commandType,
3191
- data: null,
3528
+ data: getErrorData(error),
3192
3529
  error: msg,
3193
3530
  duration: Date.now() - startTime,
3194
3531
  });
@@ -3198,6 +3535,7 @@ async function handleStatusCommand() {
3198
3535
  // to end users and clutters the output. Set SWEETLINK_DEBUG=1 to see it.
3199
3536
  if (error instanceof Error) {
3200
3537
  console.error(`[Sweetlink] ${error.message}`);
3538
+ printErrorContext(error);
3201
3539
  if (process.env.SWEETLINK_DEBUG === '1' && error.stack) {
3202
3540
  console.error(error.stack);
3203
3541
  }