feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4) (#1873)

* feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4)

Rewrites the skill from MCP-first to the spec 2 split: asset/tokens/
component route through the hyperframes figma CLI (FIGMA_TOKEN), motion/
shaders stay agent-driven over MCP (no REST equivalent). Adds two-
credential guidance, Starter rate-limit tactics (recursive:true, raw-
response cache, opt-in screenshots), the 7.1 binding flow (tokens before
components, one ask per unknown library, never value matching), and the
shader manual-export default. Catalog blurbs updated in lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): register figma component subcommand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): add storyboard-to-animatic guidance to /figma

Field-tested against a real 26-scene storyboard section: the parsing
grammar (frame-sized nodes incl. loose rectangles = scenes, x-order =
time order, TEXT below the strip = director notes paired by x-overlap),
batched still export (chunk ~4 ids per render call - big frames timeout
past ~12), a note-verb -> transition vocabulary (EXPLOSION/SLIDE/MORPH/
CYCLE), and the stills-vs-component routing rule for within-scene motion
notes. Catalog blurbs updated in lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): storyboard frames are keyframes, not slides

Field-tested against a second real storyboard section: frames sharing an
element (matched by name, else geometry similarity) define that element's
states through time - tween the element between states, crossfade only
when pixels genuinely differ, enter/exit unmatched children, tween frame
backgrounds as a color track. Stills demoted to fallback for frames that
don't decompose. Validated live: a 4-frame logo-rise reconstructed as one
element with four keyframes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(figma): self-explanatory first-run experience + mintlify guide

- NO_TOKEN/BAD_TOKEN errors now carry the full one-time setup (mint URL,
  read-only scope checklist, persist hint) instead of a bare pointer
- figma subcommands print clean guidance on typed client errors, not a
  stack trace (shared withFigmaErrors boundary)
- CLI help gains component subcommand, FIRST-TIME SETUP and WHAT TO
  EXPECT blocks
- /figma skill: preflight the token before the first CLI call and walk
  the user through setup up front; narrate landed-artifact + next action
  at every step
- new docs/guides/figma.mdx (setup, per-phase walkthroughs, provenance,
  troubleshooting table) wired into docs.json nav

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(figma): review fixes — missing withFigmaErrors imports, 401/403 semantics, docs accuracy

- tokens.ts/component.ts called withFigmaErrors without importing it
  (tsup doesn't typecheck, so every invocation shipped as an immediate
  ReferenceError); imports added, tsc --noEmit now clean
- error boundary widened to all Errors so bad-ref/bad-format input
  errors print their message instead of a stack trace
- 401 no longer claims 'missing scopes' (figma signals that as 403);
  new FORBIDDEN code maps non-variables 403 to scope/access guidance
- docs: asset/component refs require a node id (bare fileKey is
  tokens-only), example snippet matches real output, FORBIDDEN row
- skill: preflight counts a project-.env token as configured (CLI
  auto-loads it); BAD_TOKEN/FORBIDDEN guidance split

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): present figma errors via standard errorBox

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 19:13:08 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 08c0a03510
commit 566d49382c
14 changed files with 336 additions and 72 deletions
+14 -4
View File
@@ -26,11 +26,20 @@ ${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.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.accent("component")} ${c.dim("Import a frame as an editable HTML component (brand-linked colors).")}
${c.bold("ENV VARS:")}
${c.accent("FIGMA_TOKEN")} Personal access token (figma.com/settings → security).
${c.bold("FIRST-TIME SETUP:")}
1. ${c.dim("Mint a token:")} figma.com/settings → Security → Personal access tokens
2. ${c.dim("Scopes (read-only only — this integration never writes to figma):")}
File content: Read-only · File metadata: Read-only
Variables: Read-only ${c.dim("(optional — Enterprise-only brand variables)")}
3. ${c.accent('export FIGMA_TOKEN="figd_…"')} ${c.dim("— persist in your shell profile or project .env")}
${c.bold("WHAT TO EXPECT:")}
${c.dim("Every import freezes files locally under .media/ and records figma provenance —")}
${c.dim("renders never touch figma. Re-running a command re-imports only what changed.")}
${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.")}
@@ -41,6 +50,7 @@ export default defineCommand({
subCommands: {
asset: () => import("./figma/asset.js").then((m) => m.default),
tokens: () => import("./figma/tokens.js").then((m) => m.default),
component: () => import("./figma/component.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
+16 -13
View File
@@ -23,6 +23,7 @@ import {
import { existsSync } from "node:fs";
import { join, relative } from "node:path";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";
export interface AssetImportOptions {
format: FigmaAssetFormat;
@@ -124,18 +125,20 @@ export default defineCommand({
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: downloadRender },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id}${result.record.path}`);
console.log(result.snippet.html);
await withFigmaErrors(async () => {
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: downloadRender },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id}${result.record.path}`);
console.log(result.snippet.html);
});
},
});
@@ -0,0 +1,22 @@
/**
* Shared CLI error boundary for `hyperframes figma` subcommands: typed
* client errors (NO_TOKEN, BAD_TOKEN, …) and input errors (bad ref, bad
* format) all carry actionable, user-facing messages — present them via
* the CLI's standard errorBox, not a stack trace. Non-Error throws still
* surface raw.
*/
import { errorBox } from "../../ui/format.js";
export async function withFigmaErrors(fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch (err) {
if (err instanceof Error) {
const [title = "figma command failed", ...rest] = err.message.split("\n");
errorBox(title, rest.length > 0 ? rest.join("\n") : undefined);
process.exit(1);
}
throw err;
}
}
+22 -14
View File
@@ -28,6 +28,7 @@ function escapeAttr(value: string): string {
}
import { runAssetImport } from "./asset.js";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";
export interface ComponentImportDeps {
projectDir: string;
@@ -72,7 +73,12 @@ export async function runComponentImport(
{ format: "svg" },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path));
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
@@ -120,19 +126,21 @@ export default defineCommand({
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runComponentImport(args.ref, {
projectDir: args.dir,
client,
download: downloadRender,
await withFigmaErrors(async () => {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runComponentImport(args.ref, {
projectDir: args.dir,
client,
download: downloadRender,
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0)
console.log(`rasterized ${result.rasterized.length} node(s) via asset export`);
if (result.unresolved.length > 0) {
console.log(
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
);
}
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0)
console.log(`rasterized ${result.rasterized.length} node(s) via asset export`);
if (result.unresolved.length > 0) {
console.log(
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
);
}
},
});
+15 -12
View File
@@ -19,6 +19,7 @@ import {
} from "@hyperframes/core/figma";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { withFigmaErrors } from "./cliError.js";
export interface TokensImportDeps {
projectDir: string;
@@ -77,17 +78,19 @@ export default defineCommand({
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
if (result.mode === "styles") {
console.log(
"variables are Enterprise-gated on this plan — recorded published style metadata instead",
);
}
console.log(`wrote ${result.sidecarPath} (${result.mode})`);
if (result.entries.length > 0) {
console.log("add to data-composition-variables:");
console.log(JSON.stringify(result.entries, null, 2));
}
await withFigmaErrors(async () => {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
if (result.mode === "styles") {
console.log(
"variables are Enterprise-gated on this plan — recorded published style metadata instead (style values resolve at component-import time)",
);
}
console.log(`wrote ${result.sidecarPath} (${result.mode})`);
if (result.entries.length > 0) {
console.log("add to data-composition-variables:");
console.log(JSON.stringify(result.entries, null, 2));
}
});
},
});