Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
export function resolvePython(worktree: string): string {
const venvPython = resolve(worktree, '.opencode/scripts/.venv/bin/python');
if (existsSync(venvPython)) return venvPython;
return 'python3';
}
+94
View File
@@ -0,0 +1,94 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Thin wrapper over the agent-browser CLI. Opens the target URL at the requested viewport, optionally scrolls to a section, waits for networkidle plus a settle delay, then saves a cropped PNG screenshot. Used by clone-validate to capture rendered sections for diff against the captured originals.',
args: {
url: tool.schema.string().describe('URL to open (typically http://localhost:3000/)'),
viewport: tool.schema.number().describe('Viewport width in pixels (height is derived 16:9)'),
output_path: tool.schema.string().describe('Absolute path to write the PNG to'),
scroll_y: tool.schema
.number()
.nullable()
.describe('Absolute Y pixel position to scroll to before screenshot. Omit for no scroll.'),
crop: tool.schema
.object({
x: tool.schema.number(),
y: tool.schema.number(),
width: tool.schema.number(),
height: tool.schema.number(),
})
.nullable()
.describe('Crop region to extract from the viewport screenshot. Omit to keep full viewport.'),
reduce_motion: tool.schema
.boolean()
.nullable()
.describe('If true, adds the `reduce-motion` class to <html> before screenshot. Default false.'),
settle_ms: tool.schema
.number()
.nullable()
.describe('Extra ms to wait after networkidle before screenshot. Default 500.'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve, dirname } = await import('node:path');
const { mkdirSync, existsSync } = await import('node:fs');
const { resolvePython } = await import('./_python.js');
const python = resolvePython(context.worktree);
const outPath = resolve(context.worktree, args.output_path);
const outDir = dirname(outPath);
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
const height = Math.round((args.viewport * 9) / 16);
const session = `clone-validate-${args.viewport}`;
const steps: string[][] = [
['--session', session, 'set', 'viewport', String(args.viewport), String(height)],
['--session', session, 'open', args.url],
['--session', session, 'wait', '--load', 'networkidle'],
];
if (args.reduce_motion ?? false) {
steps.push(['--session', session, 'eval', 'document.documentElement.classList.add("reduce-motion")']);
}
if (args.scroll_y != null) {
steps.push(['--session', session, 'eval', `window.scrollTo({ top: ${args.scroll_y}, behavior: "instant" })`]);
}
steps.push(['--session', session, 'wait', String(args.settle_ms ?? 500)]);
steps.push(['--session', session, 'screenshot', outPath]);
for (const argv of steps) {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn('agent-browser', argv, { stdio: 'inherit' });
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`agent-browser ${argv.join(' ')} exited ${code}`));
});
child.on('error', reject);
});
}
if (args.crop != null) {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(
python,
[
'-c',
`from PIL import Image; import sys; im=Image.open(sys.argv[1]); im.crop((${args.crop!.x}, ${
args.crop!.y
}, ${args.crop!.x + args.crop!.width}, ${args.crop!.y + args.crop!.height})).save(sys.argv[1])`,
outPath,
],
{ stdio: 'inherit' }
);
child.on('exit', (code) => (code === 0 ? resolvePromise() : reject(new Error(`crop failed ${code}`))));
child.on('error', reject);
});
}
return JSON.stringify({ output_path: outPath, viewport: args.viewport });
},
});
+97
View File
@@ -0,0 +1,97 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Run the Playwright-based site capture pipeline against a target URL. Produces a full capture bundle (DOM + computed styles per scroll position, screenshots per viewport, HAR, animation library dumps, shaders, CSS rules + url() asset refs, fonts, per-section cropped screenshots, alt-height pass for vh detection) at output_dir. Wraps scripts/capture.py. Set replay=true to skip capture entirely and only re-run the post-process (vh-flags + meta.json) against an existing capture bundle — use this when iterating on prompts/agents/skills.',
args: {
url: tool.schema
.string()
.nullable()
.describe('Absolute target URL, e.g. https://example.com. Required unless replay=true.'),
viewports: tool.schema
.array(tool.schema.number())
.nullable()
.describe('Viewport widths in pixels to capture, e.g. [375, 768, 1280, 1920]. Default [375,768,1280,1920].'),
output_dir: tool.schema
.string()
.describe('Directory where the capture bundle is written (absolute path or relative to cwd)'),
wait_strategy: tool.schema
.enum(['networkidle', 'load', 'domcontentloaded'])
.nullable()
.describe('Playwright wait strategy for navigation. Default networkidle.'),
skip_third_party: tool.schema
.boolean()
.nullable()
.describe('If true, blocks requests to known third-party widget domains. Default true.'),
replay: tool.schema
.boolean()
.nullable()
.describe(
'If true, skip browser capture entirely and only re-run post-process (vh-flags + meta.json) against existing capture data in output_dir. ~100x faster than a real capture.'
),
skip_alt_height: tool.schema
.boolean()
.nullable()
.describe('If true, skip the vh-detection alt-height pass at canonical width. Saves ~10s.'),
skip_section_shots: tool.schema
.boolean()
.nullable()
.describe('If true, skip the per-section cropped screenshot pass. Saves ~30s.'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve } = await import('node:path');
const { readFileSync, existsSync } = await import('node:fs');
const { resolvePython } = await import('./_python.js');
const scriptPath = resolve(context.worktree, '.opencode/scripts/capture.py');
const outputDir = resolve(context.worktree, args.output_dir);
const python = resolvePython(context.worktree);
const replay = args.replay ?? false;
if (!replay && !args.url) {
throw new Error('capture: url is required unless replay=true');
}
const viewports = args.viewports ?? [375, 768, 1280, 1920];
const cmdArgs = [scriptPath, '--output', outputDir];
if (args.url) cmdArgs.push('--url', args.url);
if (viewports.length) cmdArgs.push('--viewports', viewports.join(','));
cmdArgs.push('--wait-strategy', args.wait_strategy ?? 'networkidle');
if (args.skip_third_party ?? true) cmdArgs.push('--skip-third-party');
if (replay) cmdArgs.push('--replay');
if (args.skip_alt_height ?? false) cmdArgs.push('--skip-alt-height');
if (args.skip_section_shots ?? false) cmdArgs.push('--skip-section-shots');
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(python, cmdArgs, { cwd: context.worktree, stdio: 'inherit' });
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`capture.py exited with code ${code}`));
});
child.on('error', reject);
});
const metaPath = resolve(outputDir, 'meta.json');
if (!existsSync(metaPath)) {
throw new Error(`capture.py did not produce ${metaPath}`);
}
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
return JSON.stringify({
status: 'success',
mode: replay ? 'replay' : 'capture',
capture_dir: outputDir,
viewports: meta.viewports ?? viewports,
dom_snapshots: meta.dom_snapshots ?? 0,
screenshots: meta.screenshots ?? 0,
section_shots: meta.section_shots ?? 0,
assets_discovered: Array.isArray(meta.assets) ? meta.assets.length : 0,
asset_sources: meta.asset_sources ?? null,
libs_detected: meta.libs_detected ?? [],
vh_relative_count: meta.vh_relative_count ?? 0,
canvas_regions: meta.canvas_regions ?? 0,
});
},
});
+63
View File
@@ -0,0 +1,63 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
"Start, stop, or health-check the Next.js dev server for the generated clone project. Use action='health' to check if a server is already responding at url; action='start' to spawn `bun run dev` in project_dir; action='stop' to kill the pid from a prior start.",
args: {
action: tool.schema.enum(['health', 'start', 'stop']).describe('Which lifecycle action to perform'),
project_dir: tool.schema.string().nullable().describe('Path to the generated Next.js project (required for start)'),
url: tool.schema.string().nullable().describe('Base URL to health-check. Default http://localhost:3000'),
port: tool.schema.number().nullable().describe('Port for `bun run dev` when starting. Default 3000.'),
pid: tool.schema.number().nullable().describe('Pid to kill when action=stop'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve } = await import('node:path');
const url = args.url ?? 'http://localhost:3000';
if (args.action === 'health') {
try {
const res = await fetch(url, { method: 'HEAD' });
return JSON.stringify({ healthy: res.ok || res.status === 404, status: res.status, url });
} catch {
return JSON.stringify({ healthy: false, status: 0, url });
}
}
if (args.action === 'start') {
if (!args.project_dir) throw new Error('project_dir is required for action=start');
const cwd = resolve(context.worktree, args.project_dir);
const port = args.port ?? 3000;
const child = spawn('bun', ['run', 'dev', '--port', String(port)], {
cwd,
detached: true,
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok || res.status === 404) {
return JSON.stringify({ status: 'started', url, pid: child.pid });
}
} catch {}
await new Promise((r) => setTimeout(r, 1000));
}
return JSON.stringify({ status: 'timeout', url, pid: child.pid });
}
if (args.action === 'stop') {
if (args.pid == null) throw new Error('pid is required for action=stop');
try {
process.kill(args.pid, 'SIGTERM');
return JSON.stringify({ status: 'stopped', pid: args.pid });
} catch (err) {
return JSON.stringify({ status: 'error', pid: args.pid, error: String(err) });
}
}
throw new Error(`unknown action ${args.action}`);
},
});
+58
View File
@@ -0,0 +1,58 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Structural diff between two DOM JSON snapshots produced by __CLONE_DUMP_COMPUTED__ — one from the source capture, one from the rendered clone (via dump-rendered). Returns a ranked list of concrete issues (missing elements, wrong dimensions, style mismatches) the generate agent can act on directly. Wraps scripts/dom-diff.py.',
args: {
captured: tool.schema.string().describe('Path to the captured DOM JSON (e.g. capture/dom/1280/step-00.json)'),
rendered: tool.schema.string().describe('Path to the rendered DOM JSON produced by dump-rendered'),
root_selector: tool.schema
.string()
.nullable()
.describe(
'Optional — scope the diff to a subtree, e.g. "#hero" or ".ecosystem". Omit to diff the whole document.'
),
max_depth: tool.schema.number().nullable().describe('Recursion depth cap. Default 8.'),
max_issues: tool.schema
.number()
.nullable()
.describe('Cap on the number of structured issues returned. Default 200.'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve } = await import('node:path');
const { resolvePython } = await import('./_python.js');
const scriptPath = resolve(context.worktree, '.opencode/scripts/dom-diff.py');
const capturedPath = resolve(context.worktree, args.captured);
const renderedPath = resolve(context.worktree, args.rendered);
const python = resolvePython(context.worktree);
const cmdArgs = [scriptPath, '--captured', capturedPath, '--rendered', renderedPath];
if (args.root_selector) cmdArgs.push('--root-selector', args.root_selector);
if (args.max_depth != null) cmdArgs.push('--max-depth', String(args.max_depth));
if (args.max_issues != null) cmdArgs.push('--max-issues', String(args.max_issues));
let stdout = '';
let stderr = '';
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(python, cmdArgs, { cwd: context.worktree });
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('exit', (code) => {
if (code === 0 || code === 2 || code === 3) resolvePromise();
else reject(new Error(`dom-diff.py exited ${code}: ${stderr}`));
});
child.on('error', reject);
});
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
return JSON.stringify({
matched: result.matched ?? 0,
counts: result.counts ?? {},
issues: result.issues ?? [],
structured_issues: result.structured_issues ?? [],
error: result.error ?? null,
});
},
});
+47
View File
@@ -0,0 +1,47 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Download every asset referenced in the capture bundle into the target Next.js project public directory using hash-based filenames. Wraps scripts/download-assets.py. Returns lists of downloaded, failed, and skipped URLs.',
args: {
manifest_path: tool.schema
.string()
.describe('Path to meta.json from capture (or manifest.json after analyze) — whichever has assets[]'),
project_public_dir: tool.schema
.string()
.describe('Path to <project>/public/assets/cloned where assets are written'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve } = await import('node:path');
const { resolvePython } = await import('./_python.js');
const scriptPath = resolve(context.worktree, '.opencode/scripts/download-assets.py');
const manifestPath = resolve(context.worktree, args.manifest_path);
const publicDir = resolve(context.worktree, args.project_public_dir);
const python = resolvePython(context.worktree);
let stdout = '';
let stderr = '';
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(python, [scriptPath, '--manifest', manifestPath, '--public-dir', publicDir, '--json'], {
cwd: context.worktree,
});
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`download-assets.py exited with code ${code}: ${stderr}`));
});
child.on('error', reject);
});
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
return JSON.stringify({
downloaded: result.downloaded ?? [],
failed: result.failed ?? [],
skipped: result.skipped ?? [],
});
},
});
+58
View File
@@ -0,0 +1,58 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Open a URL (typically the local dev server at http://localhost:3000) and dump its DOM + computed styles using the same __CLONE_DUMP_COMPUTED__ walker the source capture pipeline uses. Produces a directly-comparable structural snapshot the validate agent can pass to dom-diff. Wraps scripts/dump-rendered.py.',
args: {
url: tool.schema.string().describe('URL to dump, typically http://localhost:3000/'),
output: tool.schema.string().describe('Path to write the rendered DOM JSON (the section snapshot file)'),
viewport: tool.schema.number().nullable().describe('Viewport width in px. Default 1280.'),
viewport_height: tool.schema
.number()
.nullable()
.describe('Viewport height in px. Default = 16:9 of viewport width.'),
scroll_y: tool.schema.number().nullable().describe('Y position to scroll to before dumping. Default 0.'),
reduce_motion: tool.schema
.boolean()
.nullable()
.describe('Add reduce-motion class to <html> before dumping (matches the Stage 1 validate gate). Default false.'),
settle_ms: tool.schema.number().nullable().describe('Extra ms to wait after scroll before dumping. Default 800.'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve } = await import('node:path');
const { resolvePython } = await import('./_python.js');
const scriptPath = resolve(context.worktree, '.opencode/scripts/dump-rendered.py');
const outPath = resolve(context.worktree, args.output);
const python = resolvePython(context.worktree);
const cmdArgs = [scriptPath, '--url', args.url, '--output', outPath];
if (args.viewport != null) cmdArgs.push('--viewport', String(args.viewport));
if (args.viewport_height != null) cmdArgs.push('--viewport-height', String(args.viewport_height));
if (args.scroll_y != null) cmdArgs.push('--scroll-y', String(args.scroll_y));
if (args.reduce_motion ?? false) cmdArgs.push('--reduce-motion');
if (args.settle_ms != null) cmdArgs.push('--settle-ms', String(args.settle_ms));
let stdout = '';
let stderr = '';
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(python, cmdArgs, { cwd: context.worktree });
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`dump-rendered.py exited ${code}: ${stderr}`));
});
child.on('error', reject);
});
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
return JSON.stringify({
status: result.status ?? 'success',
output: result.output ?? outPath,
sections_path: result.sections_path,
section_count: result.section_count ?? 0,
});
},
});
+65
View File
@@ -0,0 +1,65 @@
import { tool } from '@opencode-ai/plugin';
export default tool({
description:
'Pixel-diff two PNG screenshots using pixelmatch. Returns diff percentage, path to a diff image with changed pixels highlighted, and bounding boxes of the worst regions (top connected components of changed pixels).',
args: {
before: tool.schema.string().describe('Path to the captured original PNG'),
after: tool.schema.string().describe('Path to the rendered clone PNG'),
threshold: tool.schema.number().nullable().describe('pixelmatch per-pixel color tolerance 0-1. Default 0.1.'),
diff_out: tool.schema
.string()
.nullable()
.describe('Path to write the diff PNG to. Default: same dir as after with -diff suffix.'),
},
async execute(args, context) {
const { spawn } = await import('node:child_process');
const { resolve, dirname, basename, extname, join } = await import('node:path');
const { resolvePython } = await import('./_python.js');
const scriptPath = resolve(context.worktree, '.opencode/scripts/diff.py');
const beforePath = resolve(context.worktree, args.before);
const afterPath = resolve(context.worktree, args.after);
const python = resolvePython(context.worktree);
const diffOut =
args.diff_out != null
? resolve(context.worktree, args.diff_out)
: join(dirname(afterPath), `${basename(afterPath, extname(afterPath))}-diff.png`);
let stdout = '';
let stderr = '';
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(
python,
[
scriptPath,
'--before',
beforePath,
'--after',
afterPath,
'--diff-out',
diffOut,
'--threshold',
String(args.threshold ?? 0.1),
'--json',
],
{ cwd: context.worktree }
);
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`diff.py exited with code ${code}: ${stderr}`));
});
child.on('error', reject);
});
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
return JSON.stringify({
diff_pct: result.diff_pct ?? 100,
diff_image_path: diffOut,
worst_regions: result.worst_regions ?? [],
});
},
});