Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const commandLoaders = {
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
cloud: () => import("./commands/cloud.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
figma: () => import("./commands/figma.js").then((m) => m.default),
};

// Wrap each command's run() so a thrown failure reports its reason to telemetry
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/commands/figma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* `hyperframes figma` — import figma assets, tokens, and components over
* the REST API (design spec: phases 1–3 run on REST; motion/shaders are
* MCP-only and live in the /figma skill, not this command).
*
* Requires FIGMA_TOKEN (personal access token from figma.com/settings).
*/

import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";

export const examples: Example[] = [
[
"Import a frame as a frozen SVG asset",
"hyperframes figma asset 'https://www.figma.com/design/KEY/T?node-id=1-2'",
],
["Import as PNG at 2x", "hyperframes figma asset KEY:1-2 --format png --scale 2"],
["Pull brand tokens into the composition", "hyperframes figma tokens KEY"],
["Import a frame as an editable HTML component", "hyperframes figma component KEY:10-20"],
];

const HELP = `
${c.bold("hyperframes figma")} ${c.dim("<subcommand> [args]")}

Import figma content over the REST API. Requires ${c.accent("FIGMA_TOKEN")}.

${c.bold("SUBCOMMANDS:")}
${c.accent("asset")} ${c.dim("Render a node (png/svg/jpg/pdf), freeze under .media/, print a snippet.")}
${c.accent("tokens")} ${c.dim("Import variables/styles as composition brand variables.")}

${c.bold("ENV VARS:")}
${c.accent("FIGMA_TOKEN")} Personal access token (figma.com/settings → security).

${c.dim("Motion and shader import are agent-only (figma exposes no REST endpoint for")}
${c.dim("either) — use the /figma skill in a Claude session for those.")}
`;

export default defineCommand({
meta: { name: "figma", description: "Import figma assets, tokens, and components (REST)" },
subCommands: {
asset: () => import("./figma/asset.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
},
});
104 changes: 104 additions & 0 deletions packages/cli/src/commands/figma/asset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runAssetImport, type AssetImportDeps } from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";

const dirs: string[] = [];
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), "hf-figma-asset-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});

function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
return {
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }),
imageFills: () => Promise.resolve(new Map()),
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
styles: () => Promise.resolve([]),
nodeTree: () => Promise.resolve({ id: "1:2", name: "n", type: "FRAME" }),
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
...overrides,
};
}

const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);

function deps(projectDir: string, overrides: Partial<AssetImportDeps> = {}): AssetImportDeps {
return {
projectDir,
client: fakeClient(),
download: () => Promise.resolve(PNG_BYTES),
...overrides,
};
}

describe("runAssetImport", () => {
it("freezes the render, appends a manifest record with provenance, returns a snippet", async () => {
const dir = scratch();
const out = await runAssetImport(
"https://www.figma.com/design/FILEKEY/T?node-id=1-2",
{ format: "png" },
deps(dir),
);
expect(out.record.provenance).toMatchObject({
source: "figma",
fileKey: "FILEKEY",
nodeId: "1:2",
version: "7",
format: "png",
});
expect(out.snippet.html).toContain("<img");
const frozen = readFileSync(join(dir, out.record.path));
expect(Array.from(frozen)).toEqual(Array.from(PNG_BYTES));
const manifest = readFileSync(join(dir, ".media", "manifest.jsonl"), "utf8");
expect(manifest).toContain('"fileKey":"FILEKEY"');
});

it("sanitizes svg output before freezing", async () => {
const dir = scratch();
const dirty = `<svg><script>evil()</script><rect width="1"/></svg>`;
const out = await runAssetImport(
"FILEKEY:1-2",
{ format: "svg" },
deps(dir, {
client: fakeClient({
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "svg" }),
}),
download: () => Promise.resolve(new TextEncoder().encode(dirty)),
}),
);
const frozen = readFileSync(join(dir, out.record.path), "utf8");
expect(frozen).not.toContain("script");
expect(frozen).toContain("<rect");
});

it("reuses on identical version, re-imports when the file version moved on", async () => {
const dir = scratch();
const importPng = (over?: Partial<AssetImportDeps>) =>
runAssetImport("FILEKEY:1-2", { format: "png" }, deps(dir, over));
const first = await importPng();
const sameVersion = await importPng();
expect(sameVersion.record.id).toBe(first.record.id);
expect(sameVersion.reused).toBe(true);
const bumped = await importPng({
client: fakeClient({
fileVersion: () => Promise.resolve({ version: "8", lastModified: "2026-07-02" }),
}),
});
expect(bumped.reused).toBe(false);
expect(bumped.record.id).not.toBe(first.record.id);
});

it("rejects a ref without a node id", async () => {
await expect(runAssetImport("FILEKEYONLY", { format: "png" }, deps(scratch()))).rejects.toThrow(
/node/i,
);
});
});
146 changes: 146 additions & 0 deletions packages/cli/src/commands/figma/asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* `hyperframes figma asset <ref>` — Phase 1 of the figma integration:
* render a node over REST, sanitize (svg), freeze under .media/, record
* provenance in the shared manifest, print a composition snippet.
*/

import { defineCommand } from "citty";
import {
appendRecord,
buildAssetSnippet,
createFigmaClient,
findByFigmaNode,
freezeBytes,
nextId,
parseFigmaRef,
sanitizeSvg,
typeDirPath,
type AssetSnippet,
type FigmaAssetFormat,
type FigmaClient,
type FigmaManifestRecord,
} from "@hyperframes/core/figma";
import { existsSync } from "node:fs";
import { join, relative } from "node:path";

export interface AssetImportOptions {
format: FigmaAssetFormat;
scale?: number;
}

export interface AssetImportDeps {
projectDir: string;
client: FigmaClient;
/** fetch a short-lived figma CDN url into bytes; injectable for tests */
download: (url: string) => Promise<Uint8Array>;
}

export interface AssetImportResult {
record: FigmaManifestRecord;
snippet: AssetSnippet;
reused: boolean;
}

async function defaultDownload(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}

export async function runAssetImport(
refInput: string,
opts: AssetImportOptions,
deps: AssetImportDeps,
): Promise<AssetImportResult> {
const ref = parseFigmaRef(refInput);
if (!ref.nodeId)
throw new Error(
`ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`,
);

const { version } = await deps.client.fileVersion(ref.fileKey);

// Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse.
// Unspecified scale is canonically 1 on both sides (figma's default), so
// `--scale 1` and no flag dedupe to the same record. Reuse also requires
// the frozen file to still exist — a deleted file falls through to
// re-import instead of returning a snippet that points at nothing.
const existing = findByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId);
if (
existing &&
existing.provenance.format === opts.format &&
(existing.provenance.scale ?? 1) === (opts.scale ?? 1) &&
existing.provenance.version === version &&
existsSync(join(deps.projectDir, existing.path))
) {
return { record: existing, snippet: buildAssetSnippet(existing), reused: true };
}

const rendered = await deps.client.renderNode(ref, opts);
let bytes = await deps.download(rendered.url);
if (rendered.ext === "svg") {
// Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A
// non-text payload would decode to U+FFFD soup and still write to disk.
const b0 = bytes[0];
if (b0 !== 0x3c && b0 !== 0x3f && b0 !== 0xef)
throw new Error("figma render returned non-SVG bytes for an svg export — retry the import");
bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes)));
}

const id = nextId(deps.projectDir, "image");
const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${rendered.ext}`);
freezeBytes(bytes, destAbs);

const record: FigmaManifestRecord = {
id,
type: "image",
path: relative(deps.projectDir, destAbs),
source: `figma:${ref.fileKey}/${ref.nodeId}`,
provenance: {
source: "figma",
fileKey: ref.fileKey,
nodeId: ref.nodeId,
version,
format: opts.format,
scale: opts.scale,
},
};
appendRecord(deps.projectDir, record);
return { record, snippet: buildAssetSnippet(record), reused: false };
}

const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"];

function parseFormat(raw: string): FigmaAssetFormat {
for (const f of FORMATS) if (f === raw) return f;
throw new Error(`unsupported format "${raw}" — use one of ${FORMATS.join(", ")}`);
}

export default defineCommand({
meta: { name: "asset", description: "Import a figma node as a frozen local asset" },
args: {
ref: {
type: "positional",
description: "figma URL, fileKey:nodeId, or fileKey",
required: true,
},
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
scale: { type: "string", description: "export scale (e.g. 2)" },
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const token = process.env.FIGMA_TOKEN ?? "";
const client = createFigmaClient({ token });
const result = await runAssetImport(
args.ref,
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
},
{ projectDir: args.dir, client, download: defaultDownload },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
console.log(result.snippet.html);
},
});
82 changes: 82 additions & 0 deletions packages/core/src/figma/bindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @vitest-environment node
import { describe, expect, it, afterEach, beforeEach } from "vitest";
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
appendBinding,
upsertBindings,
findBindingByFigmaId,
readBindings,
readLibraryMap,
recordLibraryFile,
type FigmaBindingRecord,
} from "./bindings";

let dir = "";
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-bindings-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

const REC: FigmaBindingRecord = {
kind: "binding",
figmaId: "VariableID:1:23",
key: "abc123",
sourceFileKey: "FILE",
compositionVariableId: "figma:Blue/500",
version: "7",
};

describe("bindings index", () => {
it("round-trips binding records through .media/figma-bindings.jsonl", () => {
expect(readBindings(dir)).toEqual([]);
appendBinding(dir, REC);
appendBinding(dir, {
...REC,
figmaId: "VariableID:1:24",
compositionVariableId: "figma:Red/500",
});
const all = readBindings(dir);
expect(all).toHaveLength(2);
expect(all[0]?.compositionVariableId).toBe("figma:Blue/500");
});

it("findBindingByFigmaId matches exact ids only — never values or names", () => {
appendBinding(dir, REC);
expect(findBindingByFigmaId(dir, "VariableID:1:23")?.key).toBe("abc123");
expect(findBindingByFigmaId(dir, "VariableID:1:99")).toBeNull();
expect(findBindingByFigmaId(dir, "Blue/500")).toBeNull();
});

it("matches alias-chain members too (semantic id bound, primitive in chain)", () => {
appendBinding(dir, { ...REC, aliasChain: ["VariableID:9:1", "VariableID:1:23"] });
expect(findBindingByFigmaId(dir, "VariableID:9:1")?.compositionVariableId).toBe(
"figma:Blue/500",
);
});

it("persists answered library-file mappings (asked once per project)", () => {
expect(readLibraryMap(dir)).toEqual({});
recordLibraryFile(dir, "libkey-1", "LIBFILE");
expect(readLibraryMap(dir)).toEqual({ "libkey-1": "LIBFILE" });
});

it("skips malformed lines instead of crashing", () => {
appendBinding(dir, REC);
appendFileSync(join(dir, ".media", "figma-bindings.jsonl"), "not json\n");
expect(readBindings(dir)).toHaveLength(1);
});

it("upsert replaces stale rows for re-imported figmaIds, keeps others + library rows", () => {
appendBinding(dir, REC);
appendBinding(dir, { ...REC, figmaId: "VariableID:2:2", compositionVariableId: "figma:Red" });
recordLibraryFile(dir, "libkey-2", "LIB2");
upsertBindings(dir, [{ ...REC, compositionVariableId: "figma:Blue/500-v2", version: "9" }]);
const all = readBindings(dir);
expect(all).toHaveLength(2);
expect(findBindingByFigmaId(dir, REC.figmaId)?.compositionVariableId).toBe("figma:Blue/500-v2");
expect(findBindingByFigmaId(dir, "VariableID:2:2")?.compositionVariableId).toBe("figma:Red");
expect(readLibraryMap(dir)["libkey-2"]).toBe("LIB2");
});
});
Loading
Loading