@vizzly-testing/cli 0.30.0 → 0.32.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 (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -7,12 +7,16 @@ import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceConte
7
7
  import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js';
8
8
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
9
  import * as defaultOutput from '../utils/output.js';
10
+ import { readSession as defaultReadSession } from '../utils/session.js';
10
11
  function buildAuthErrorMessage() {
11
- return 'API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"';
12
+ return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.';
12
13
  }
13
14
  function buildSourceErrorMessage() {
14
15
  return '--source must be one of: auto, cloud, local';
15
16
  }
17
+ function buildIncludeErrorMessage() {
18
+ return '--include must contain only: screenshots, diffs, comments';
19
+ }
16
20
  function validateLimitRange(value, flagName, {
17
21
  min = 1,
18
22
  max
@@ -21,7 +25,7 @@ function validateLimitRange(value, flagName, {
21
25
  return [];
22
26
  }
23
27
  if (!Number.isInteger(value) || value < min || value > max) {
24
- return [`${flagName} must be a number between ${min} and ${max}`];
28
+ return [`${flagName} must be an integer between ${min} and ${max}`];
25
29
  }
26
30
  return [];
27
31
  }
@@ -30,7 +34,7 @@ function validateOffset(value) {
30
34
  return [];
31
35
  }
32
36
  if (!Number.isInteger(value) || value < 0) {
33
- return ['--offset must be a non-negative number'];
37
+ return ['--offset must be a non-negative integer'];
34
38
  }
35
39
  return [];
36
40
  }
@@ -43,6 +47,18 @@ function validateSourceOption(value) {
43
47
  }
44
48
  return [];
45
49
  }
50
+ function parseIncludeOption(value) {
51
+ if (!value) {
52
+ return [];
53
+ }
54
+ let rawItems = Array.isArray(value) ? value : String(value).split(',');
55
+ return rawItems.map(item => item.trim()).filter(Boolean);
56
+ }
57
+ function validateIncludeOption(value) {
58
+ let allowed = new Set(['screenshots', 'diffs', 'comments']);
59
+ let invalid = parseIncludeOption(value).filter(item => !allowed.has(item));
60
+ return invalid.length > 0 ? [buildIncludeErrorMessage()] : [];
61
+ }
46
62
  function validateScopedProjectOptions(options = {}) {
47
63
  let errors = [];
48
64
  if (options.org && !options.project) {
@@ -50,10 +66,13 @@ function validateScopedProjectOptions(options = {}) {
50
66
  }
51
67
  return errors;
52
68
  }
69
+ function hasExplicitCloudScope(options = {}, config = {}) {
70
+ return Boolean(options.org || options.project || config.linkedProject?.organizationSlug || config.linkedProject?.projectSlug);
71
+ }
53
72
  function createClient(config, createApiClient) {
54
73
  return createApiClient({
55
74
  baseUrl: config.apiUrl,
56
- token: config.apiKey,
75
+ token: config.apiKey || config.userToken,
57
76
  command: 'context'
58
77
  });
59
78
  }
@@ -69,7 +88,7 @@ async function loadContextConfig(globalOptions, options, deps) {
69
88
  ...options
70
89
  };
71
90
  let config = await loadConfig(globalOptions.config, allOptions);
72
- if (requireApiKey && !config.apiKey) {
91
+ if (requireApiKey && !config.apiKey && !config.userToken) {
73
92
  output.error(buildAuthErrorMessage());
74
93
  output.cleanup();
75
94
  exit(1);
@@ -111,6 +130,23 @@ function buildLocalFingerprintCapabilityError() {
111
130
  error.code = 'LOCAL_WORKSPACE_CONTEXT';
112
131
  return error;
113
132
  }
133
+ function resolveBuildContextId(buildId, runtime, deps = {}) {
134
+ let {
135
+ readSession = defaultReadSession
136
+ } = deps;
137
+ if (buildId !== 'current' || runtime.source !== 'cloud') {
138
+ return buildId;
139
+ }
140
+ let session = readSession({
141
+ cwd: runtime.projectRoot
142
+ });
143
+ if (session?.buildId && !session.expired) {
144
+ return session.buildId;
145
+ }
146
+ let error = new Error('No current cloud build found. Run "vizzly run" first, or pass a build ID.');
147
+ error.code = 'NO_CURRENT_CLOUD_BUILD';
148
+ throw error;
149
+ }
114
150
  function shouldExplainLocalSimilarityGap(requestedSource, command, localProvider) {
115
151
  return requestedSource === 'auto' && command === 'similar' && localProvider.isAvailable();
116
152
  }
@@ -136,11 +172,12 @@ async function loadContextRuntime(command, target, globalOptions, options, deps
136
172
  requestedSource,
137
173
  command,
138
174
  target,
139
- projectRoot
175
+ projectRoot,
176
+ hasCloudScope: hasExplicitCloudScope(options, config)
140
177
  }, {
141
178
  createLocalWorkspaceContextProvider
142
179
  });
143
- if (source === 'cloud' && !config.apiKey) {
180
+ if (source === 'cloud' && !config.apiKey && !config.userToken) {
144
181
  if (shouldExplainLocalSimilarityGap(requestedSource, command, localProvider)) {
145
182
  throw buildLocalFingerprintCapabilityError();
146
183
  }
@@ -183,6 +220,189 @@ function getStatusTone(colors, status) {
183
220
  function getComparisonDisplayState(comparison = {}) {
184
221
  return comparison.result || comparison.status || 'unknown';
185
222
  }
223
+ function isChangedComparison(comparison = {}) {
224
+ return ['changed', 'failed', 'pending'].includes(getComparisonDisplayState(comparison));
225
+ }
226
+ function isNewComparison(comparison = {}) {
227
+ return getComparisonDisplayState(comparison) === 'new';
228
+ }
229
+ function getComparisonName(comparison = {}) {
230
+ return comparison.screenshot_name || comparison.screenshot?.name || comparison.name || comparison.id || 'unknown screenshot';
231
+ }
232
+ function getComparisonDiffPercentage(comparison = {}) {
233
+ return comparison.diff?.percentage ?? comparison.diff_percentage ?? null;
234
+ }
235
+ function getComparisonFingerprint(comparison = {}) {
236
+ return comparison.diff?.fingerprint_hash || comparison.analysis?.fingerprint_hash || null;
237
+ }
238
+ function getComparisonDiffImageUrl(comparison = {}) {
239
+ return comparison.diff?.image_url || comparison.analysis?.diff_image_url || null;
240
+ }
241
+ function getComparisonRegionCount(comparison = {}) {
242
+ let regions = comparison.diff?.regions || comparison.analysis?.diff_regions;
243
+ return Array.isArray(regions) ? regions.length : 0;
244
+ }
245
+ function getComparisonScreenshot(comparison = {}) {
246
+ return comparison.screenshot || {};
247
+ }
248
+ function getComparisonBaseline(comparison = {}) {
249
+ return comparison.baseline || comparison.screenshot?.baseline || {};
250
+ }
251
+ function buildCompactDiff(comparison = {}, includeDiffs = false) {
252
+ let diff = comparison.diff || comparison.analysis || {};
253
+ let compact = {
254
+ percentage: getComparisonDiffPercentage(comparison),
255
+ changed_pixels: diff.changed_pixels ?? comparison.changed_pixels ?? null,
256
+ total_pixels: diff.total_pixels ?? comparison.total_pixels ?? null,
257
+ threshold: diff.threshold ?? comparison.threshold ?? null,
258
+ fingerprint_hash: getComparisonFingerprint(comparison),
259
+ region_count: getComparisonRegionCount(comparison),
260
+ image_url: getComparisonDiffImageUrl(comparison)
261
+ };
262
+ if (includeDiffs) {
263
+ compact.regions = diff.regions || diff.diff_regions || [];
264
+ compact.cluster_metadata = diff.cluster_metadata || null;
265
+ compact.ssim_score = diff.ssim_score ?? null;
266
+ compact.gmsd_score = diff.gmsd_score ?? null;
267
+ compact.diff_lines = diff.diff_lines || [];
268
+ }
269
+ return compact;
270
+ }
271
+ function buildCompactComparison(comparison = {}, {
272
+ includeDiffs = false
273
+ } = {}) {
274
+ let screenshot = getComparisonScreenshot(comparison);
275
+ let baseline = getComparisonBaseline(comparison);
276
+ return {
277
+ id: comparison.id || null,
278
+ name: getComparisonName(comparison),
279
+ result: getComparisonDisplayState(comparison),
280
+ approval_status: comparison.approval_status || null,
281
+ needs_review: Boolean(comparison.needs_review),
282
+ is_flaky: Boolean(comparison.is_flaky),
283
+ screenshot: {
284
+ id: screenshot.id || null,
285
+ url: screenshot.url || screenshot.original_url || null
286
+ },
287
+ baseline: {
288
+ id: baseline.id || null,
289
+ build_id: baseline.build_id || null,
290
+ url: baseline.url || baseline.original_url || null
291
+ },
292
+ diff: buildCompactDiff(comparison, includeDiffs)
293
+ };
294
+ }
295
+ function summarizeComparisons(comparisons = []) {
296
+ return {
297
+ total: comparisons.length,
298
+ changed: comparisons.filter(isChangedComparison).length,
299
+ new: comparisons.filter(isNewComparison).length,
300
+ needs_review: comparisons.filter(comparison => comparison.needs_review).length
301
+ };
302
+ }
303
+ function buildAgentNextActions(context = {}, comparisonSummary = {}) {
304
+ if (context.status?.needs_review) {
305
+ return ['Inspect the changed and new comparisons before editing related UI.', 'Use approved baselines as the expected visual behavior.', 'Leave approval decisions to human reviewers.'];
306
+ }
307
+ if (comparisonSummary.total > 0) {
308
+ return ['Use the approved baseline and reviewed screenshots as current visual truth.', 'Prefer targeted edits that preserve identical comparisons.'];
309
+ }
310
+ return ['No comparisons were returned for this build context.', 'Open the build URL for more detail if this is unexpected.'];
311
+ }
312
+ function formatAgentStatus(status = {}) {
313
+ return {
314
+ needs_review: Boolean(status.needs_review),
315
+ reasons: status.reasons || [],
316
+ pending_comparisons: status.pending_comparisons || 0,
317
+ unresolved_comments: status.unresolved_comments || 0
318
+ };
319
+ }
320
+ function formatAgentSummary(context = {}, comparisons = []) {
321
+ return {
322
+ comparisons: context.summary?.comparisons || summarizeComparisons(comparisons),
323
+ review: context.summary?.review || {},
324
+ comments: context.summary?.comments || {
325
+ build: getBuildCommentsCount(context),
326
+ screenshot: getScreenshotCommentsCount(context)
327
+ }
328
+ };
329
+ }
330
+ function buildAgentBuildPayload(context, {
331
+ source = null,
332
+ include = [],
333
+ evidenceLimit = 10
334
+ } = {}) {
335
+ let comparisons = context.comparisons || [];
336
+ let includeSet = new Set(include);
337
+ let includeDiffs = includeSet.has('diffs');
338
+ let changed = comparisons.filter(isChangedComparison);
339
+ let fresh = comparisons.filter(isNewComparison);
340
+ let evidence = [...changed, ...fresh].slice(0, evidenceLimit).map(comparison => buildCompactComparison(comparison, {
341
+ includeDiffs
342
+ }));
343
+ let comparisonSummary = summarizeComparisons(comparisons);
344
+ let payload = {
345
+ resource: 'build_agent_context',
346
+ source: context.source || source || 'cloud',
347
+ scope: context.scope || null,
348
+ project: {
349
+ organization: context.scope?.organization?.slug || null,
350
+ slug: context.scope?.project?.slug || null,
351
+ name: context.scope?.project?.name || null,
352
+ visibility: context.scope?.project?.visibility || null
353
+ },
354
+ build: context.build || null,
355
+ baseline: {
356
+ selected: context.baseline?.selected || null,
357
+ selection_reason: context.baseline?.selection_reason || null
358
+ },
359
+ status: formatAgentStatus(context.status),
360
+ summary: formatAgentSummary(context, comparisons),
361
+ evidence,
362
+ links: context.links || {},
363
+ preview: context.preview || null,
364
+ next_actions: buildAgentNextActions(context, comparisonSummary)
365
+ };
366
+ if (includeSet.has('screenshots')) {
367
+ payload.screenshots = context.screenshots || [];
368
+ }
369
+ if (includeSet.has('comments')) {
370
+ payload.comments = context.comments || {};
371
+ }
372
+ return payload;
373
+ }
374
+ function getBuildCommentsCount(context = {}) {
375
+ if (Array.isArray(context.comments?.build)) {
376
+ return context.comments.build.length;
377
+ }
378
+ if (Array.isArray(context.review?.comments)) {
379
+ return context.review.comments.length;
380
+ }
381
+ return 0;
382
+ }
383
+ function getScreenshotCommentsCount(context = {}) {
384
+ if (Number.isInteger(context.comments?.screenshot_count)) {
385
+ return context.comments.screenshot_count;
386
+ }
387
+ return 0;
388
+ }
389
+ function getReviewAssignmentsCount(context = {}) {
390
+ if (Array.isArray(context.review?.assignments)) {
391
+ return context.review.assignments.length;
392
+ }
393
+ return 0;
394
+ }
395
+ function formatNeedsReview(status = {}) {
396
+ if (!status || status.needs_review == null) {
397
+ return null;
398
+ }
399
+ let pending = status.pending_comparisons || 0;
400
+ let unresolved = status.unresolved_comments || 0;
401
+ if (!status.needs_review) {
402
+ return 'no';
403
+ }
404
+ return `yes · ${pending} comparisons · ${unresolved} unresolved comments`;
405
+ }
186
406
  function formatConfirmedRegionLabels(regions = []) {
187
407
  return regions.map(region => region.label).filter(Boolean).slice(0, 3).join(' · ');
188
408
  }
@@ -193,13 +413,17 @@ function printComparisonList(output, comparisons = [], {
193
413
  for (let comparison of comparisons.slice(0, limit)) {
194
414
  let displayState = getComparisonDisplayState(comparison);
195
415
  let statusTone = getStatusTone(colors, displayState);
196
- let screenshotName = comparison.screenshot?.name || comparison.name || comparison.id;
197
- let diffPercentage = comparison.diff_percentage == null ? null : `${comparison.diff_percentage}%`;
198
- let fingerprint = comparison.analysis?.fingerprint_hash || null;
416
+ let screenshotName = getComparisonName(comparison);
417
+ let rawDiffPercentage = getComparisonDiffPercentage(comparison);
418
+ let diffPercentage = rawDiffPercentage == null ? null : `${rawDiffPercentage}%`;
419
+ let fingerprint = getComparisonFingerprint(comparison);
199
420
  let details = [];
200
421
  if (diffPercentage) {
201
422
  details.push(diffPercentage);
202
423
  }
424
+ if (comparison.needs_review) {
425
+ details.push('needs review');
426
+ }
203
427
  if (fingerprint) {
204
428
  details.push(`fp:${fingerprint}`);
205
429
  }
@@ -216,23 +440,94 @@ function displayBuildContext(output, context) {
216
440
  output.header('context', 'build');
217
441
  let colors = output.getColors();
218
442
  let buildTone = getStatusTone(colors, context.build.status);
443
+ let comparisons = context.comparisons || [];
444
+ let screenshots = context.screenshots || [];
445
+ let reviewSummary = context.summary?.review || {};
446
+ let commentsSummary = context.summary?.comments || {};
447
+ let needsReview = formatNeedsReview(context.status);
448
+ let baseline = context.baseline?.selected || null;
219
449
  output.print(` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}`);
220
450
  output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
221
451
  output.blank();
222
- output.labelValue('Comparisons', String(context.comparisons.length));
223
- output.labelValue('Review', `${context.summary.review.pending || 0} pending · ${context.summary.review.approved || 0} approved · ${context.summary.review.rejected || 0} rejected`);
224
- output.labelValue('Memory', `${context.review.comments.length} build comments · ${context.review.assignments.length} assignments`);
452
+ output.labelValue('Comparisons', String(comparisons.length));
453
+ if (screenshots.length > 0) {
454
+ output.labelValue('Screenshots', String(screenshots.length));
455
+ }
456
+ if (baseline) {
457
+ output.labelValue('Baseline', `${baseline.name || baseline.id || 'selected'}${context.baseline.selection_reason ? ` · ${context.baseline.selection_reason}` : ''}`);
458
+ }
459
+ if (needsReview) {
460
+ output.labelValue('Needs Review', needsReview);
461
+ }
462
+ output.labelValue('Review', `${reviewSummary.pending || 0} pending · ${reviewSummary.approved || 0} approved · ${reviewSummary.rejected || 0} rejected`);
463
+ output.labelValue('Memory', `${commentsSummary.build ?? getBuildCommentsCount(context)} build comments · ${commentsSummary.screenshot ?? getScreenshotCommentsCount(context)} screenshot comments · ${getReviewAssignmentsCount(context)} assignments`);
225
464
  if (context.preview) {
226
- output.labelValue('Preview', `${context.preview.status}${context.preview.preview_url ? ' · available' : ''}`);
465
+ let previewUrl = context.preview.preview_url || context.preview.url;
466
+ output.labelValue('Preview', `${context.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}`);
227
467
  }
228
468
  if (context.links?.build_url) {
229
469
  output.labelValue('Build URL', context.links.build_url);
230
470
  }
231
- if (context.comparisons.length > 0) {
471
+ if (comparisons.length > 0) {
232
472
  output.blank();
233
473
  output.print(' Comparisons');
234
- printComparisonList(output, context.comparisons);
474
+ printComparisonList(output, comparisons);
475
+ }
476
+ }
477
+ function formatAgentBuildContext(context) {
478
+ let comparisons = context.comparisons || [];
479
+ let changed = comparisons.filter(isChangedComparison);
480
+ let fresh = comparisons.filter(isNewComparison);
481
+ let needsReview = comparisons.filter(comparison => comparison.needs_review);
482
+ let baseline = context.baseline?.selected;
483
+ let lines = [`# Vizzly Visual Context: ${context.build?.name || context.build?.id || 'Build'}`, '', `Project: ${context.scope?.organization?.slug || 'unknown'}/${context.scope?.project?.slug || 'unknown'}`, `Build: ${context.build?.id || 'unknown'} (${context.build?.status || 'unknown'})`];
484
+ if (baseline) {
485
+ lines.push(`Approved baseline: ${baseline.name || baseline.id || 'selected'} (${baseline.approval_status || 'unknown'})`);
486
+ }
487
+ if (context.status) {
488
+ lines.push(`Needs review: ${context.status.needs_review ? 'yes' : 'no'} (${context.status.pending_comparisons || 0} pending comparisons)`);
489
+ }
490
+ if (context.preview?.url || context.preview?.preview_url) {
491
+ lines.push(`Preview: ${context.preview.url || context.preview.preview_url}`);
492
+ }
493
+ if (context.links?.build_url) {
494
+ lines.push(`Build URL: ${context.links.build_url}`);
495
+ }
496
+ if (context.links?.report_url) {
497
+ lines.push(`Report: ${context.links.report_url}`);
498
+ }
499
+ lines.push('');
500
+ lines.push('## Diff Summary');
501
+ lines.push(`- Total comparisons: ${comparisons.length}`);
502
+ lines.push(`- Changed: ${changed.length}`);
503
+ lines.push(`- New: ${fresh.length}`);
504
+ lines.push(`- Needs review: ${needsReview.length}`);
505
+ if (changed.length > 0 || fresh.length > 0) {
506
+ lines.push('');
507
+ lines.push('## Evidence To Inspect');
508
+ for (let comparison of [...changed, ...fresh].slice(0, 10)) {
509
+ let diffPercentage = getComparisonDiffPercentage(comparison);
510
+ let detail = diffPercentage == null ? '' : ` · ${diffPercentage}% diff`;
511
+ let diffUrl = comparison.diff?.image_url || comparison.analysis?.diff_image_url;
512
+ lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}${detail}`);
513
+ if (diffUrl) {
514
+ lines.push(` Diff: ${diffUrl}`);
515
+ }
516
+ }
517
+ }
518
+ if (comparisons.length > 0 && changed.length === 0 && fresh.length === 0) {
519
+ lines.push('');
520
+ lines.push('## Reviewed Screenshots');
521
+ for (let comparison of comparisons.slice(0, 10)) {
522
+ lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}`);
523
+ }
524
+ if (comparisons.length > 10) {
525
+ lines.push(`- ...${comparisons.length - 10} more`);
526
+ }
235
527
  }
528
+ lines.push('');
529
+ lines.push('Use this as reviewed UI context. Treat approved baselines as visual truth, inspect meaningful diffs, and leave approval decisions to humans.');
530
+ return lines.join('\n');
236
531
  }
237
532
  function countScreenshotCommentEntries(groups = []) {
238
533
  return groups.reduce((total, group) => total + (group.comments?.length || 0), 0);
@@ -335,14 +630,28 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
335
630
  if (!runtime) {
336
631
  return;
337
632
  }
633
+ let resolvedBuildId = resolveBuildContextId(buildId, runtime, deps);
338
634
  output.startSpinner('Fetching build context...');
339
- let context = await runtime.provider.getBuildContext(buildId);
635
+ let context = await runtime.provider.getBuildContext(resolvedBuildId);
340
636
  output.stopSpinner();
637
+ if (globalOptions.json && options.agent && !options.full) {
638
+ output.data(buildAgentBuildPayload(context, {
639
+ source: runtime.source,
640
+ include: parseIncludeOption(options.include)
641
+ }));
642
+ output.cleanup();
643
+ return;
644
+ }
341
645
  if (globalOptions.json) {
342
646
  output.data(context);
343
647
  output.cleanup();
344
648
  return;
345
649
  }
650
+ if (options.agent) {
651
+ output.print(formatAgentBuildContext(context));
652
+ output.cleanup();
653
+ return;
654
+ }
346
655
  displayBuildContext(output, context);
347
656
  output.cleanup();
348
657
  } catch (error) {
@@ -513,7 +822,9 @@ export async function contextReviewQueueCommand(options = {}, globalOptions = {}
513
822
  }
514
823
  }
515
824
  export function validateContextBuildOptions(_options = {}) {
516
- return validateSourceOption(_options.source);
825
+ let errors = validateSourceOption(_options.source);
826
+ errors.push(...validateIncludeOption(_options.include));
827
+ return errors;
517
828
  }
518
829
  export function validateContextComparisonOptions(options = {}) {
519
830
  let errors = [];