Files
hyperframes/packages/cli/src/capture/agentPromptGenerator.ts
T
Xuanru LiandJerrai a779206430 feat(cli): capture accepts a local HTML file again (#6498)
* feat(cli): capture accepts a local HTML file again

Adds an explicit --local-file mode. The file's directory (or --local-root) is served
on a loopback port that only Chrome may reach outside the capture proxy; all other
traffic keeps the public-address policy. The URL argument stays public HTTP(S) only.

* fix(cli): local capture output names the file, not the loopback server

In --local-file mode, meta.json, AGENTS.md, extracted files and the returned result
used the temporary 127.0.0.1 origin. One rewriter maps that origin to file:// URLs
under the root, and meta.json records the file as its source. Root and served paths
now resolve with the same native realpath, which Windows needs.

* fix(cli): local capture reads its files from disk and resolves real paths natively

The asset downloader read the local page server with Node's fetch, which an environment proxy
(NODE_USE_ENV_PROXY with HTTP_PROXY) intercepts, so local images and fonts were dropped. It now
reads the file from disk under the server's own checks.

Root and served paths both use the native realpath. The JS realpath kept Windows 8.3 short names
while fs/promises expanded them, so every file answered 404 on Windows.

The default root is the directory the file was named in, so a symlinked entry can no longer widen
it to wherever the link points.

The local-file docs move below the capture policy text so their heading does not swallow it.

* refactor(cli): capture settles a watchdog outcome in one place

* fix(cli): escape every RegExp metacharacter in the local origin rewriter

---------

Co-authored-by: Jerrai <jerrai@heygen.com>
2026-10-27 04:45:32 +00:00

216 lines
8.4 KiB
TypeScript

/**
* Generate AGENTS.md and CLAUDE.md for captured website projects.
*
* Writes the same content to both filenames so any AI agent auto-discovers it:
* - AGENTS.md — universal convention (Cursor, Codex, Gemini CLI, Windsurf, Aider, Jules)
* - CLAUDE.md — Claude Code convention
*
* This file generates a DATA INVENTORY that tells the AI agent what files
* exist and what they contain. How to make a film from it lives in the
* hyperframes skill's references/film.md and references/types/launch.md — this file points agents there.
*/
import { readdirSync, existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { captureSiteName } from "./localPage.js";
import { join } from "node:path";
import type { DesignTokens } from "./types.js";
import type { AnimationCatalog } from "./animationCataloger.js";
import type { CatalogedAsset } from "./assetCataloger.js";
/**
* Infer a human-readable role hint from a hex color based on luminance and saturation.
* Not a substitute for DESIGN.md — just helps orient agents scanning the brand summary.
*/
// fallow-ignore-next-line complexity
function inferColorRole(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
if (isNaN(r) || isNaN(g) || isNaN(b)) return "color";
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const saturation = max === 0 ? 0 : (max - min) / max;
if (luminance < 0.04) return "bg-dark";
if (luminance > 0.9) return "bg-light";
if (saturation > 0.4 && luminance > 0.05 && luminance < 0.7) return "accent";
if (luminance < 0.2) return "surface-dark";
if (luminance > 0.7) return "surface-light";
return "neutral";
}
export function generateAgentPrompt(
outputDir: string,
url: string,
tokens: DesignTokens,
_animations: AnimationCatalog | undefined, // reserved for future animation summary
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
_catalogedAssets?: CatalogedAsset[], // reserved for future asset inventory
_detectedLibraries?: string[],
): void {
const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders);
writeCaptureFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
writeCaptureFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
writeCaptureFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
}
// fallow-ignore-next-line complexity
function buildPrompt(
outputDir: string,
url: string,
tokens: DesignTokens,
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
): string {
const title = tokens.title || captureSiteName(url);
const colorSummary = tokens.colors
.slice(0, 10)
.map((hex) => `${hex} (${inferColorRole(hex)})`)
.join(", ");
const fontSummary =
tokens.fonts
.map(
(f) =>
f.family +
(f.variable && f.weightRange
? ` (${f.weightRange[0]}-${f.weightRange[1]} variable)`
: f.weights.length > 0
? ` (${f.weights.join(",")})`
: ""),
)
.join(", ") || "none detected";
// Build the data inventory table rows
// Helper: find all contact sheet pages for a given base name. Matches the
// exact base file plus paginated variants only (e.g. `contact-sheet.jpg`,
// `contact-sheet-2.jpg`, `contact-sheet-3.jpg`). The "-NNN" suffix is digits
// only, so unrelated files that happen to share the prefix (notably the
// `contact-sheet-svgs.jpg` SVG fallback sheet in assets/) don't get mixed in.
function contactSheetRows(dir: string, baseFile: string, label: string): string[] {
const fullDir = join(outputDir, dir);
if (!existsSync(fullDir)) return [];
const baseName = baseFile.replace(/\.jpg$/, "");
// Escape regex metacharacters in baseName so future callers can pass
// filenames containing `.`, `+`, `(`, etc. without the regex breaking.
const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const paginatedRe = new RegExp(`^${escapedBase}(?:-(\\d+))?\\.jpg$`);
// Sort by the numeric page suffix so `contact-sheet-10.jpg` lands after
// `contact-sheet-2.jpg`, not before (default string sort orders them
// lexicographically and breaks at 10+ pages). Unpaginated `contact-sheet.jpg`
// gets page 0 so it sorts first if it co-exists with paginated files.
const all = readdirSync(fullDir)
.filter((f) => paginatedRe.test(f))
.map((f) => ({ name: f, page: parseInt(f.match(paginatedRe)?.[1] ?? "0", 10) }))
.sort((a, b) => a.page - b.page)
.map((entry) => entry.name);
if (all.length === 0) return [];
if (all.length === 1) {
return [`| \`${dir}/${all[0]}\` | ${label} |`];
}
return all.map((f, i) => `| \`${dir}/${f}\` | ${label} — page ${i + 1} of ${all.length} |`);
}
const tableRows: string[] = [];
if (hasScreenshot) {
const screenshotRows = contactSheetRows(
"screenshots",
"contact-sheet.jpg",
"**View this first.** All scroll screenshots in labeled grid — see the entire page at a glance",
);
if (screenshotRows.length > 0) {
tableRows.push(...screenshotRows);
} else {
tableRows.push(
"| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in one labeled grid. |",
);
}
tableRows.push(
"| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |",
);
}
tableRows.push(
`| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${tokens.headings?.length ?? 0} headings, ${tokens.ctas?.length ?? 0} CTAs |`,
);
// design-styles.json is written from a try/catch in capture/index.ts and
// gets skipped when the live-DOM style extraction fails. Only list it in the
// agent prompt when it actually exists, so the agent isn't pointed at a 404.
if (existsSync(join(outputDir, "extracted", "design-styles.json"))) {
tableRows.push(
"| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |",
);
}
tableRows.push(
"| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |",
);
tableRows.push(
"| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |",
);
if (hasLottie) {
tableRows.push(
"| `extracted/lottie-manifest.json` | Lottie animations with previews at `assets/lottie/previews/`. |",
);
}
if (hasShaders) {
tableRows.push("| `extracted/shaders.json` | WebGL shader source (GLSL). |");
}
// Asset contact sheets — dynamically list all pages
const assetSheetRows = contactSheetRows(
"assets",
"contact-sheet.jpg",
"Downloaded images in labeled grid — view before opening individual files",
);
if (assetSheetRows.length > 0) {
tableRows.push(...assetSheetRows);
} else {
tableRows.push("| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |");
}
// SVG contact sheets — check both assets/svgs/ and assets/ root fallback
const svgSubdirRows = contactSheetRows(
"assets/svgs",
"contact-sheet.jpg",
"SVGs rendered as thumbnails in labeled grid",
);
const svgRootRows = contactSheetRows(
"assets",
"contact-sheet-svgs.jpg",
"SVGs rendered as thumbnails in labeled grid",
);
const svgRows = svgSubdirRows.length > 0 ? svgSubdirRows : svgRootRows;
if (svgRows.length > 0) {
tableRows.push(...svgRows);
}
tableRows.push("| `assets/` | Individual downloaded images, SVGs, and font files. |");
// Brand summary — just the essentials
const brandLines: string[] = [];
brandLines.push(`- **Colors**: ${colorSummary || "see tokens.json"}`);
brandLines.push(`- **Fonts**: ${fontSummary}`);
return `# ${title}
Source: ${url}
To create a video from this capture, use the \`hyperframes\` skill and read its \`references/film.md\` (From a website) and \`references/types/launch.md\`.
## What's in This Capture
| File | Contents |
|------|----------|
${tableRows.join("\n")}
## Brand Summary
${brandLines.join("\n")}
`;
}