diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 79252c030..261dda459 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -179,7 +179,8 @@ export default defineCommand({ dir: { type: "string", description: "project directory", default: "." }, }, async run({ args }) { - await withFigmaErrors(async () => { + await withFigmaErrors("figma:asset", async () => { + const t0 = Date.now(); const token = process.env.FIGMA_TOKEN ?? ""; const client = createFigmaClient({ token }); const result = await runAssetImport( @@ -195,6 +196,8 @@ export default defineCommand({ const verb = result.reused ? "reused" : "imported"; console.log(`${verb} ${result.record.id} → ${result.record.path}`); console.log(result.snippet.html); + const { trackFigmaImport } = await import("../../telemetry/index.js"); + trackFigmaImport({ phase: "asset", reused: result.reused, durationMs: Date.now() - t0 }); }); }, }); diff --git a/packages/cli/src/commands/figma/cliError.ts b/packages/cli/src/commands/figma/cliError.ts index 610181bd3..33e34ae01 100644 --- a/packages/cli/src/commands/figma/cliError.ts +++ b/packages/cli/src/commands/figma/cliError.ts @@ -4,15 +4,36 @@ * 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. + * + * Because this exits the process itself, it must ALSO report the failure + * inline (the top-level trackCommandFailures wrapper never sees it) — the + * typed error name (FigmaClientError code) is the whole first-run funnel: + * NO_TOKEN → later success is onboarding conversion. */ +import { FigmaClientError } from "@hyperframes/core/figma"; import { errorBox } from "../../ui/format.js"; -export async function withFigmaErrors(fn: () => Promise): Promise { +export async function withFigmaErrors(command: string, fn: () => Promise): Promise { try { await fn(); } catch (err) { if (err instanceof Error) { + try { + const telemetry = await import("../../telemetry/index.js"); + // Surface the typed code (NO_TOKEN, BAD_TOKEN, RATE_LIMITED, …) as the + // error name — `FigmaClientError` alone says nothing in a dashboard. + telemetry.trackCliError({ + error_name: err instanceof FigmaClientError ? err.code : err.name, + error_message: err.message, + stack_trace: err.stack, + command, + kind: "command_error", + }); + await telemetry.flush(); + } catch { + // Telemetry must never mask the real command failure. + } const [title = "figma command failed", ...rest] = err.message.split("\n"); errorBox(title, rest.length > 0 ? rest.join("\n") : undefined); process.exit(1); diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts index b80a2f9f0..d99b805be 100644 --- a/packages/cli/src/commands/figma/component.ts +++ b/packages/cli/src/commands/figma/component.ts @@ -130,7 +130,8 @@ export default defineCommand({ dir: { type: "string", description: "project directory", default: "." }, }, async run({ args }) { - await withFigmaErrors(async () => { + await withFigmaErrors("figma:component", async () => { + const t0 = Date.now(); const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" }); const result = await runComponentImport(args.ref, { projectDir: args.dir, @@ -145,6 +146,13 @@ export default defineCommand({ `${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.`, ); } + const { trackFigmaImport } = await import("../../telemetry/index.js"); + trackFigmaImport({ + phase: "component", + unresolvedBindings: result.unresolved.length, + rasterizedNodes: result.rasterized.length, + durationMs: Date.now() - t0, + }); }); }, }); diff --git a/packages/cli/src/commands/figma/skillContent.test.ts b/packages/cli/src/commands/figma/skillContent.test.ts new file mode 100644 index 000000000..6d1f980e8 --- /dev/null +++ b/packages/cli/src/commands/figma/skillContent.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Semantic pin for the /figma skill's telemetry instructions: the MCP-only +// phases (motion/shaders/storyboards) have NO CLI touchpoint, so the beacon +// wording in SKILL.md is the only thing that produces their usage signal. The +// manifest hash proves the skill changed; this proves a future prompt edit +// didn't silently drop the beacon slugs or the completion event. +const SKILL_MD = readFileSync( + join( + fileURLToPath(new URL(".", import.meta.url)), + "..", + "..", + "..", + "..", + "..", + "skills", + "figma", + "SKILL.md", + ), + "utf8", +); + +describe("figma SKILL.md telemetry beacons", () => { + it("instructs the beacon for every MCP-only phase", () => { + expect(SKILL_MD).toContain("figma-motion"); + expect(SKILL_MD).toContain("figma-shaders"); + expect(SKILL_MD).toContain("figma-storyboard"); + expect(SKILL_MD).toContain("hyperframes events"); + }); + + it("instructs the completion beacon with an outcome", () => { + expect(SKILL_MD).toContain("--event=skill_completed"); + expect(SKILL_MD).toMatch(/--outcome=success\|error/); + }); +}); diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts index f00b840e3..a1cc6a69e 100644 --- a/packages/cli/src/commands/figma/tokens.ts +++ b/packages/cli/src/commands/figma/tokens.ts @@ -78,7 +78,8 @@ export default defineCommand({ dir: { type: "string", description: "project directory", default: "." }, }, async run({ args }) { - await withFigmaErrors(async () => { + await withFigmaErrors("figma:tokens", async () => { + const t0 = Date.now(); const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" }); const result = await runTokensImport(args.ref, { projectDir: args.dir, client }); if (result.mode === "styles") { @@ -91,6 +92,13 @@ export default defineCommand({ console.log("add to data-composition-variables:"); console.log(JSON.stringify(result.entries, null, 2)); } + const { trackFigmaImport } = await import("../../telemetry/index.js"); + trackFigmaImport({ + phase: "tokens", + tokensMode: result.mode, + entryCount: result.entries.length, + durationMs: Date.now() - t0, + }); }); }, }); diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 76567b932..1a4aa0890 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -11,6 +11,7 @@ const { trackRenderObservation, trackCommandFailure, trackCliError, + trackFigmaImport, trackRenderFeedback, trackRenderPreflightRejected, } = await import("./events.js"); @@ -187,3 +188,41 @@ describe("trackCommandFailure", () => { ); }); }); + +describe("trackFigmaImport", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("emits figma_import with phase + quality counters, no identifiers", () => { + trackFigmaImport({ + phase: "component", + durationMs: 1234, + unresolvedBindings: 2, + rasterizedNodes: 3, + }); + expect(trackEvent).toHaveBeenCalledWith("figma_import", { + phase: "component", + duration_ms: 1234, + unresolved_bindings: 2, + rasterized_nodes: 3, + }); + }); + + it("carries reused for the asset phase and omits absent props entirely", () => { + trackFigmaImport({ phase: "asset", durationMs: 42, reused: true }); + expect(trackEvent).toHaveBeenCalledWith("figma_import", { + phase: "asset", + duration_ms: 42, + reused: true, + }); + }); + + it("carries tokens mode + entry count for the tokens phase", () => { + trackFigmaImport({ phase: "tokens", durationMs: 10, tokensMode: "styles", entryCount: 0 }); + expect(trackEvent).toHaveBeenCalledWith( + "figma_import", + expect.objectContaining({ phase: "tokens", tokens_mode: "styles", entry_count: 0 }), + ); + }); +}); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index d2fa57bde..ec4bf891d 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -338,6 +338,33 @@ export function trackCliError(props: { }); } +/** + * One figma import outcome (asset/tokens/component). Carries capability mix, + * dedup effectiveness, and fidelity-degradation counts — never fileKeys, + * node ids, names, or descriptions. + */ +export function trackFigmaImport(props: { + phase: "asset" | "tokens" | "component"; + durationMs: number; + reused?: boolean; + tokensMode?: "variables" | "styles"; + entryCount?: number; + unresolvedBindings?: number; + rasterizedNodes?: number; +}): void { + trackEvent("figma_import", { + phase: props.phase, + duration_ms: props.durationMs, + ...(props.reused !== undefined ? { reused: props.reused } : {}), + ...(props.tokensMode !== undefined ? { tokens_mode: props.tokensMode } : {}), + ...(props.entryCount !== undefined ? { entry_count: props.entryCount } : {}), + ...(props.unresolvedBindings !== undefined + ? { unresolved_bindings: props.unresolvedBindings } + : {}), + ...(props.rasterizedNodes !== undefined ? { rasterized_nodes: props.rasterizedNodes } : {}), + }); +} + // Report why a command failed before it exits non-zero. cli_command_result // records the failure but not the reason; this fills that gap via cli_error so // command failures are diagnosable. Enqueues synchronously — the process `exit` diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index ce2c0c12f..8da1cb110 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -8,5 +8,6 @@ export { trackBrowserInstall, trackCliError, trackCommandResult, + trackFigmaImport, } from "./events.js"; export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js"; diff --git a/skills-manifest.json b/skills-manifest.json index 1a48a1f65..eba337998 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "0adc2a1e01767db7", + "hash": "4c3977e79a17d498", "files": 1 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 17e0f3985..78da14516 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -77,6 +77,8 @@ Node tree → editable HTML at exact figma geometry, packaged as a registry item ## Motion (Phase 4 — MCP, the headline) +**Usage beacon:** MCP phases have no CLI touchpoint, so fire the skill beacon at start and finish (anonymous, consent-gated, never fails): `npx hyperframes events --skill=figma-motion` when you begin, `npx hyperframes events --skill=figma-motion --event=skill_completed --outcome=success|error` when done. Same for shaders (`figma-shaders`) and storyboards (`figma-storyboard`). + No REST equivalent exists. You drive the MCP tools, then hand output to the pure helpers in `@hyperframes/core/figma`: 1. `get_motion_context(fileKey, nodeId)` — use `recursive:true` on the parent frame (one call for the whole scene, not one per element). Save the raw JSON next to the project (`.media/figma-cache/`) so retranslation is free.