@nick848/fet 1.1.12 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  FetError,
4
4
  toFetError
5
- } from "../chunk-J5WB4KAL.js";
5
+ } from "../chunk-YKRUVIJ6.js";
6
6
 
7
7
  // src/cli/index.ts
8
8
  import { createInterface as createInterface3 } from "readline/promises";
@@ -2080,7 +2080,13 @@ function renderFetConfig(scan, language = "zh-CN") {
2080
2080
  figmaGuard: {
2081
2081
  enabled: true,
2082
2082
  mode: "require_before_ui",
2083
- onUncertainty: "stop_and_ask"
2083
+ onUncertainty: "stop_and_ask",
2084
+ sync: {
2085
+ enabled: true,
2086
+ apiKeyEnv: "FIGMA_ACCESS_TOKEN",
2087
+ inlineImageMaxBytes: 32768,
2088
+ requireBeforeUi: true
2089
+ }
2084
2090
  },
2085
2091
  uiDisplayContract: {
2086
2092
  enabled: true
@@ -2530,17 +2536,27 @@ function figmaStopHandoffRelativePath(changeId) {
2530
2536
  function figmaApplyInstructionsRelativePath(changeId) {
2531
2537
  return `openspec/changes/${changeId}/.fet/figma-apply-instructions.md`;
2532
2538
  }
2539
+ function figmaDesignManifestRelativePath(changeId) {
2540
+ return `openspec/changes/${changeId}/.fet/figma-design-manifest.yaml`;
2541
+ }
2542
+ function figmaSyncInstructionsRelativePath(changeId) {
2543
+ return `openspec/changes/${changeId}/.fet/figma-sync-instructions.md`;
2544
+ }
2533
2545
  function renderFigmaRequireBeforeUiBody(language, changeId) {
2534
2546
  const stopPath = figmaStopHandoffRelativePath(changeId);
2547
+ const syncPath = figmaSyncInstructionsRelativePath(changeId);
2548
+ const manifestPath3 = figmaDesignManifestRelativePath(changeId);
2535
2549
  if (language === "en") {
2536
2550
  return `## Mandatory before any UI implementation
2537
2551
 
2538
2552
  Complete these steps **before** writing or editing UI code (components, pages, styles, layout):
2539
2553
 
2540
- 1. Read \`${stopPath}\` for detected Figma links and stop rules.
2541
- 2. Use **Figma MCP/API** (or an approved Figma tool) to read every linked frame/node referenced by this change.
2542
- 3. In your reply, briefly list design facts you confirmed from Figma (frames, colors, typography, spacing, components, states).
2543
- 4. Only then implement UI tasks from \`tasks.md\`.
2554
+ 1. Run \`fet figma sync --change ${changeId}\` (requires \`FIGMA_ACCESS_TOKEN\` or \`--token\`) so FET downloads **all nodes** and **image assets** into \`.fet/figma-assets/\`.
2555
+ 2. Read \`${syncPath}\`, \`${manifestPath3}\`, and \`figma-nodes.json\` in the same folder\u2014use on-disk asset paths for backgrounds and large images (not base64 in source).
2556
+ 3. Read \`${stopPath}\` for detected Figma links and stop rules.
2557
+ 4. If sync is impossible, use **Figma MCP/API** to read every linked frame/node; do not skip image fills or exportable layers.
2558
+ 5. In your reply, briefly list design facts you confirmed (frames, colors, typography, spacing, components, states, asset paths).
2559
+ 6. Only then implement UI tasks from \`tasks.md\`.
2544
2560
 
2545
2561
  ## Forbidden
2546
2562
 
@@ -2556,10 +2572,12 @@ Follow the stop rules in \`${stopPath}\`. Pause implementation, explain what fai
2556
2572
 
2557
2573
  \u5728\u7F16\u5199\u6216\u4FEE\u6539 UI \u4EE3\u7801\uFF08\u7EC4\u4EF6\u3001\u9875\u9762\u3001\u6837\u5F0F\u3001\u5E03\u5C40\uFF09**\u4E4B\u524D**\uFF0C\u6309\u987A\u5E8F\u5B8C\u6210\uFF1A
2558
2574
 
2559
- 1. \u9605\u8BFB \`${stopPath}\` \u4E2D\u7684 Figma \u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
2560
- 2. \u4F7F\u7528 **Figma MCP/API**\uFF08\u6216\u5DF2\u914D\u7F6E\u7684 Figma \u5DE5\u5177\uFF09\u8BFB\u53D6\u672C change \u5F15\u7528\u7684\u6BCF\u4E2A\u753B\u677F/\u8282\u70B9\u3002
2561
- 3. \u5728\u56DE\u590D\u4E2D\u7B80\u8981\u5217\u51FA\u5DF2\u4ECE Figma \u786E\u8BA4\u7684\u8BBE\u8BA1\u4E8B\u5B9E\uFF08\u753B\u677F\u3001\u989C\u8272\u3001\u5B57\u53F7\u3001\u95F4\u8DDD\u3001\u7EC4\u4EF6\u3001\u72B6\u6001\u7B49\uFF09\u3002
2562
- 4. \u5B8C\u6210\u4EE5\u4E0A\u6B65\u9AA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD UI \u4EFB\u52A1\u3002
2575
+ 1. \u6267\u884C \`fet figma sync --change ${changeId}\`\uFF08\u9700 \`FIGMA_ACCESS_TOKEN\` \u6216 \`--token\`\uFF09\uFF0C\u7531 FET \u62C9\u53D6**\u5168\u90E8\u8282\u70B9**\u5E76\u5C06\u56FE\u7247\u7D20\u6750\u843D\u76D8\u5230 \`.fet/figma-assets/\`\u3002
2576
+ 2. \u9605\u8BFB\u540C\u76EE\u5F55 \`${syncPath}\`\u3001\`${manifestPath3}\`\u3001\`figma-nodes.json\`\uFF1B\u5927\u56FE\u7528\u6587\u4EF6\u8DEF\u5F84\u5F15\u7528\uFF0C\u7981\u6B62\u5728\u6E90\u7801\u4E2D\u5185\u8054\u5927\u4F53\u79EF base64\u3002
2577
+ 3. \u9605\u8BFB \`${stopPath}\` \u4E2D\u7684 Figma \u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
2578
+ 4. \u82E5\u65E0\u6CD5 sync\uFF0C\u5219\u7528 **Figma MCP/API** \u8BFB\u53D6\u6BCF\u4E2A\u753B\u677F/\u8282\u70B9\uFF0C\u4E0D\u5F97\u9057\u6F0F\u56FE\u7247\u586B\u5145\u4E0E\u53EF\u5BFC\u51FA\u56FE\u5C42\u3002
2579
+ 5. \u5728\u56DE\u590D\u4E2D\u7B80\u8981\u5217\u51FA\u5DF2\u786E\u8BA4\u7684\u8BBE\u8BA1\u4E8B\u5B9E\uFF08\u753B\u677F\u3001\u989C\u8272\u3001\u5B57\u53F7\u3001\u95F4\u8DDD\u3001\u7EC4\u4EF6\u3001\u72B6\u6001\u3001\u7D20\u6750\u8DEF\u5F84\uFF09\u3002
2580
+ 6. \u5B8C\u6210\u4EE5\u4E0A\u6B65\u9AA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD UI \u4EFB\u52A1\u3002
2563
2581
 
2564
2582
  ## \u7981\u6B62
2565
2583
 
@@ -2702,16 +2720,21 @@ function renderFigmaStopNextStep(changeId, language) {
2702
2720
  const path = figmaStopHandoffRelativePath(changeId);
2703
2721
  return language === "en" ? `Before UI implementation, read ${path}. If Figma access fails or design details are unclear, stop and ask the user\u2014do not guess styles.` : `\u5B9E\u65BD UI \u524D\u9605\u8BFB ${path}\u3002Figma \u8BBF\u95EE\u5931\u8D25\u6216\u8BBE\u8BA1\u7EC6\u8282\u4E0D\u660E\u786E\u65F6\u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u4E0D\u8981\u731C\u6D4B\u6837\u5F0F\u3002`;
2704
2722
  }
2705
- function renderFigmaApplyNextSteps(changeId, language, mode) {
2723
+ function renderFigmaApplyNextSteps(changeId, language, mode, options) {
2706
2724
  const applyPath = figmaApplyInstructionsRelativePath(changeId);
2707
2725
  const stopPath = figmaStopHandoffRelativePath(changeId);
2726
+ const syncPath = figmaSyncInstructionsRelativePath(changeId);
2727
+ const apiKeyEnv = options?.apiKeyEnv ?? "FIGMA_ACCESS_TOKEN";
2708
2728
  if (mode === "require_before_ui") {
2729
+ const syncStep = options?.syncDone === true ? language === "en" ? `Figma design data is synced. Read ${syncPath} and figma-design-manifest.yaml before UI work.` : `Figma \u8BBE\u8BA1\u6570\u636E\u5DF2\u540C\u6B65\u3002\u5B9E\u65BD UI \u524D\u9605\u8BFB ${syncPath} \u4E0E figma-design-manifest.yaml\u3002` : options?.syncMissingToken ? language === "en" ? `Run fet figma sync --change ${changeId} with ${apiKeyEnv} set to fetch all nodes and assets before UI work.` : `\u5B9E\u65BD UI \u524D\u6267\u884C fet figma sync --change ${changeId}\uFF08\u914D\u7F6E ${apiKeyEnv}\uFF09\uFF0C\u62C9\u53D6\u5168\u90E8\u8282\u70B9\u4E0E\u7D20\u6750\u3002` : language === "en" ? `Run fet figma sync --change ${changeId} if not already synced; then read ${syncPath}.` : `\u82E5\u5C1A\u672A\u540C\u6B65\uFF0C\u6267\u884C fet figma sync --change ${changeId}\uFF0C\u5E76\u9605\u8BFB ${syncPath}\u3002`;
2709
2730
  return language === "en" ? [
2710
- `Before any UI task: read and follow ${applyPath} (mandatory). Use Figma MCP/API for every linked frame\u2014do not invent styles.`,
2731
+ syncStep,
2732
+ `Before any UI task: read and follow ${applyPath} (mandatory). Use synced assets and node tree\u2014do not invent styles.`,
2711
2733
  `If Figma access fails or design details are unclear, stop per ${stopPath} and ask the user before continuing.`,
2712
2734
  `After Figma is confirmed, read openspec/changes/${changeId}/tasks.md and implement pending tasks.`
2713
2735
  ] : [
2714
- `\u5B9E\u65BD\u4EFB\u4F55 UI \u4EFB\u52A1\u524D\uFF1A\u5FC5\u987B\u9605\u8BFB\u5E76\u9075\u5B88 ${applyPath}\uFF1B\u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u94FE\u63A5\u7684\u753B\u677F\uFF0C\u7981\u6B62\u81EA\u521B\u6837\u5F0F\u3002`,
2736
+ syncStep,
2737
+ `\u5B9E\u65BD\u4EFB\u4F55 UI \u4EFB\u52A1\u524D\uFF1A\u5FC5\u987B\u9605\u8BFB\u5E76\u9075\u5B88 ${applyPath}\uFF1B\u4F7F\u7528\u5DF2\u540C\u6B65\u7684\u8282\u70B9\u6811\u4E0E\u7D20\u6750\u8DEF\u5F84\uFF0C\u7981\u6B62\u81EA\u521B\u6837\u5F0F\u3002`,
2715
2738
  `Figma \u8BBF\u95EE\u5931\u8D25\u6216\u8BBE\u8BA1\u7EC6\u8282\u4E0D\u6E05\uFF1A\u6309 ${stopPath} \u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u786E\u8BA4\u540E\u518D\u7EE7\u7EED\u3002`,
2716
2739
  `\u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u9605\u8BFB openspec/changes/${changeId}/tasks.md \u5E76\u5B9E\u65BD\u5F85\u529E\u4EFB\u52A1\u3002`
2717
2740
  ];
@@ -3545,8 +3568,8 @@ async function exists4(path) {
3545
3568
  }
3546
3569
 
3547
3570
  // src/commands/proxy.ts
3548
- import { readFile as readFile17 } from "fs/promises";
3549
- import { join as join22 } from "path";
3571
+ import { readFile as readFile19 } from "fs/promises";
3572
+ import { join as join26 } from "path";
3550
3573
 
3551
3574
  // src/figma-guard.ts
3552
3575
  import { readdir as readdir3, readFile as readFile10, stat as stat7 } from "fs/promises";
@@ -3701,11 +3724,757 @@ async function readOptional3(path) {
3701
3724
  }
3702
3725
  }
3703
3726
 
3727
+ // src/figma/config.ts
3728
+ import { readFile as readFile11 } from "fs/promises";
3729
+ import { join as join17 } from "path";
3730
+ import { parseDocument as parseDocument3 } from "yaml";
3731
+ var DEFAULT_FIGMA_SYNC_CONFIG = {
3732
+ enabled: true,
3733
+ apiKeyEnv: "FIGMA_ACCESS_TOKEN",
3734
+ inlineImageMaxBytes: 32768,
3735
+ requireBeforeUi: true
3736
+ };
3737
+ async function loadFigmaSyncConfig(projectRoot) {
3738
+ try {
3739
+ const raw = await readFile11(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3740
+ const doc = parseDocument3(raw);
3741
+ const fetNode = doc.get("fet", true);
3742
+ const guardNode = fetNode?.get?.("figmaGuard");
3743
+ const syncNode = guardNode?.get?.("sync");
3744
+ if (!syncNode || typeof syncNode.get !== "function") {
3745
+ return DEFAULT_FIGMA_SYNC_CONFIG;
3746
+ }
3747
+ const enabled = syncNode.get("enabled");
3748
+ const apiKeyEnv = syncNode.get("apiKeyEnv");
3749
+ const inlineImageMaxBytes = syncNode.get("inlineImageMaxBytes");
3750
+ const requireBeforeUi = syncNode.get("requireBeforeUi");
3751
+ return {
3752
+ enabled: enabled === void 0 ? true : Boolean(enabled),
3753
+ apiKeyEnv: typeof apiKeyEnv === "string" && apiKeyEnv.trim() ? apiKeyEnv.trim() : DEFAULT_FIGMA_SYNC_CONFIG.apiKeyEnv,
3754
+ inlineImageMaxBytes: typeof inlineImageMaxBytes === "number" && inlineImageMaxBytes > 0 ? inlineImageMaxBytes : DEFAULT_FIGMA_SYNC_CONFIG.inlineImageMaxBytes,
3755
+ requireBeforeUi: requireBeforeUi === void 0 ? true : Boolean(requireBeforeUi)
3756
+ };
3757
+ } catch {
3758
+ return DEFAULT_FIGMA_SYNC_CONFIG;
3759
+ }
3760
+ }
3761
+ function resolveFigmaAccessToken(config, explicitToken) {
3762
+ if (explicitToken?.trim()) {
3763
+ return explicitToken.trim();
3764
+ }
3765
+ const fromEnv = process.env[config.apiKeyEnv];
3766
+ return fromEnv?.trim() ? fromEnv.trim() : null;
3767
+ }
3768
+
3769
+ // src/figma/sync.ts
3770
+ import { mkdir as mkdir7, writeFile as writeFile2 } from "fs/promises";
3771
+ import { join as join21 } from "path";
3772
+
3773
+ // src/tdd/fingerprint.ts
3774
+ import { createHash } from "crypto";
3775
+ import { readdir as readdir4, readFile as readFile12, stat as stat8 } from "fs/promises";
3776
+ import { join as join18, relative as relative3 } from "path";
3777
+ async function collectPlanningSources(projectRoot, changeId) {
3778
+ const changeRoot = join18(projectRoot, "openspec", "changes", changeId);
3779
+ const sources = [];
3780
+ const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3781
+ for (const name of rootFiles) {
3782
+ const path = join18(changeRoot, name);
3783
+ if (await exists5(path)) {
3784
+ sources.push(relative3(projectRoot, path).replace(/\\/g, "/"));
3785
+ }
3786
+ }
3787
+ const specsDir = join18(changeRoot, "specs");
3788
+ if (await exists5(specsDir)) {
3789
+ for (const file of await walkFiles(specsDir)) {
3790
+ if (file.endsWith(".md")) {
3791
+ sources.push(relative3(projectRoot, file).replace(/\\/g, "/"));
3792
+ }
3793
+ }
3794
+ }
3795
+ return sources.sort();
3796
+ }
3797
+ async function computePlanningFingerprint(projectRoot, changeId) {
3798
+ const sources = await collectPlanningSources(projectRoot, changeId);
3799
+ const hash = createHash("sha256");
3800
+ for (const source of sources) {
3801
+ const content = await readFile12(join18(projectRoot, source), "utf8");
3802
+ hash.update(source);
3803
+ hash.update("\0");
3804
+ hash.update(content);
3805
+ hash.update("\0");
3806
+ }
3807
+ return `sha256:${hash.digest("hex")}`;
3808
+ }
3809
+ async function walkFiles(dir) {
3810
+ const entries = await readdir4(dir, { withFileTypes: true });
3811
+ const files = [];
3812
+ for (const entry of entries) {
3813
+ const path = join18(dir, entry.name);
3814
+ if (entry.isDirectory()) {
3815
+ files.push(...await walkFiles(path));
3816
+ } else if (entry.isFile()) {
3817
+ files.push(path);
3818
+ }
3819
+ }
3820
+ return files;
3821
+ }
3822
+ async function exists5(path) {
3823
+ try {
3824
+ await stat8(path);
3825
+ return true;
3826
+ } catch {
3827
+ return false;
3828
+ }
3829
+ }
3830
+
3831
+ // src/figma/api.ts
3832
+ var FIGMA_API_BASE = "https://api.figma.com/v1";
3833
+ async function fetchFigmaFile(fileKey, accessToken, nodeIds) {
3834
+ const params = new URLSearchParams();
3835
+ if (nodeIds?.length) {
3836
+ params.set("ids", nodeIds.join(","));
3837
+ params.set("depth", "8");
3838
+ }
3839
+ const query = params.size ? `?${params.toString()}` : "";
3840
+ const url = nodeIds?.length ? `${FIGMA_API_BASE}/files/${fileKey}/nodes${query}` : `${FIGMA_API_BASE}/files/${fileKey}${query}`;
3841
+ const response = await fetch(url, {
3842
+ headers: { "X-Figma-Token": accessToken }
3843
+ });
3844
+ if (!response.ok) {
3845
+ throw new FetError({
3846
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3847
+ message: `Figma API request failed (${response.status})`,
3848
+ details: { fileKey, status: response.status, statusText: response.statusText },
3849
+ recoverable: true,
3850
+ suggestedCommand: "Check FIGMA_ACCESS_TOKEN and file permissions, then rerun fet figma sync."
3851
+ });
3852
+ }
3853
+ const body = await response.json();
3854
+ if (body.nodes && nodeIds?.length) {
3855
+ const first = nodeIds.map((id) => body.nodes?.[id]?.document).find(Boolean);
3856
+ if (!first) {
3857
+ throw new FetError({
3858
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3859
+ message: "Figma API returned no nodes for the requested node-id.",
3860
+ details: { fileKey, nodeIds },
3861
+ recoverable: true
3862
+ });
3863
+ }
3864
+ return {
3865
+ name: body.name ?? fileKey,
3866
+ document: first,
3867
+ components: body.components,
3868
+ styles: body.styles
3869
+ };
3870
+ }
3871
+ if (!body.document) {
3872
+ throw new FetError({
3873
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3874
+ message: "Figma API returned an empty document.",
3875
+ details: { fileKey },
3876
+ recoverable: true
3877
+ });
3878
+ }
3879
+ return {
3880
+ name: body.name ?? fileKey,
3881
+ document: body.document,
3882
+ components: body.components,
3883
+ styles: body.styles
3884
+ };
3885
+ }
3886
+ async function fetchFigmaImageUrls(fileKey, accessToken, imageRefs) {
3887
+ if (!imageRefs.length) {
3888
+ return {};
3889
+ }
3890
+ const response = await fetch(`${FIGMA_API_BASE}/files/${fileKey}/images`, {
3891
+ headers: { "X-Figma-Token": accessToken }
3892
+ });
3893
+ if (!response.ok) {
3894
+ throw new FetError({
3895
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3896
+ message: `Figma image metadata request failed (${response.status})`,
3897
+ details: { fileKey, status: response.status },
3898
+ recoverable: true
3899
+ });
3900
+ }
3901
+ const body = await response.json();
3902
+ const result = {};
3903
+ for (const ref of imageRefs) {
3904
+ const url = body.images?.[ref];
3905
+ if (url) {
3906
+ result[ref] = url;
3907
+ }
3908
+ }
3909
+ return result;
3910
+ }
3911
+ async function fetchFigmaNodeExports(fileKey, accessToken, nodeIds, format = "png") {
3912
+ if (!nodeIds.length) {
3913
+ return {};
3914
+ }
3915
+ const params = new URLSearchParams({
3916
+ ids: nodeIds.join(","),
3917
+ format,
3918
+ scale: "2"
3919
+ });
3920
+ const response = await fetch(`${FIGMA_API_BASE}/images/${fileKey}?${params.toString()}`, {
3921
+ headers: { "X-Figma-Token": accessToken }
3922
+ });
3923
+ if (!response.ok) {
3924
+ return {};
3925
+ }
3926
+ const body = await response.json();
3927
+ const result = {};
3928
+ for (const id of nodeIds) {
3929
+ const url = body.images?.[id];
3930
+ if (url) {
3931
+ result[id] = url;
3932
+ }
3933
+ }
3934
+ return result;
3935
+ }
3936
+ async function downloadBinary(url) {
3937
+ const response = await fetch(url);
3938
+ if (!response.ok) {
3939
+ throw new FetError({
3940
+ code: "FILE_SYSTEM_ERROR" /* FileSystemError */,
3941
+ message: `Failed to download Figma asset (${response.status})`,
3942
+ details: { url },
3943
+ recoverable: true
3944
+ });
3945
+ }
3946
+ const buffer = new Uint8Array(await response.arrayBuffer());
3947
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
3948
+ return { bytes: buffer, mimeType };
3949
+ }
3950
+
3951
+ // src/figma/download-assets.ts
3952
+ import { mkdir as mkdir6, writeFile } from "fs/promises";
3953
+ import { join as join19 } from "path";
3954
+
3955
+ // src/figma/paths.ts
3956
+ function figmaFetDirRelative(changeId) {
3957
+ return `openspec/changes/${changeId}/.fet`;
3958
+ }
3959
+ function figmaDesignManifestRelativePath2(changeId) {
3960
+ return `${figmaFetDirRelative(changeId)}/figma-design-manifest.yaml`;
3961
+ }
3962
+ function figmaNodesRelativePath(changeId) {
3963
+ return `${figmaFetDirRelative(changeId)}/figma-nodes.json`;
3964
+ }
3965
+ function figmaSyncInstructionsRelativePath2(changeId) {
3966
+ return `${figmaFetDirRelative(changeId)}/figma-sync-instructions.md`;
3967
+ }
3968
+ function figmaAssetsDirRelative(changeId) {
3969
+ return `${figmaFetDirRelative(changeId)}/figma-assets`;
3970
+ }
3971
+ function figmaAssetFileRelative(changeId, fileKey, assetId, ext) {
3972
+ const safeId = assetId.replace(/:/g, "-");
3973
+ return `${figmaAssetsDirRelative(changeId)}/${fileKey}/${safeId}.${ext}`;
3974
+ }
3975
+
3976
+ // src/figma/download-assets.ts
3977
+ function extensionForMime(mimeType) {
3978
+ if (mimeType.includes("svg")) {
3979
+ return "svg";
3980
+ }
3981
+ if (mimeType.includes("jpeg") || mimeType.includes("jpg")) {
3982
+ return "jpg";
3983
+ }
3984
+ if (mimeType.includes("webp")) {
3985
+ return "webp";
3986
+ }
3987
+ return "png";
3988
+ }
3989
+ function toBase64(bytes) {
3990
+ return Buffer.from(bytes).toString("base64");
3991
+ }
3992
+ async function downloadFigmaAssets(options) {
3993
+ const assets = [];
3994
+ const assetsRoot = join19(options.projectRoot, "openspec", "changes", options.changeId, ".fet", "figma-assets", options.fileKey);
3995
+ await mkdir6(assetsRoot, { recursive: true });
3996
+ const refUrls = await fetchFigmaImageUrls(options.fileKey, options.accessToken, [
3997
+ ...new Set(options.imageRefs.map((item) => item.ref))
3998
+ ]);
3999
+ for (const item of options.imageRefs) {
4000
+ const url = refUrls[item.ref];
4001
+ if (!url) {
4002
+ continue;
4003
+ }
4004
+ const { bytes, mimeType } = await downloadBinary(url);
4005
+ const ext = extensionForMime(mimeType);
4006
+ const assetId = `ref-${item.ref}`;
4007
+ const relativePath = figmaAssetFileRelative(options.changeId, options.fileKey, assetId, ext);
4008
+ const entry = await persistAsset({
4009
+ projectRoot: options.projectRoot,
4010
+ relativePath,
4011
+ assetId,
4012
+ name: item.name,
4013
+ nodeId: item.nodeId,
4014
+ imageRef: item.ref,
4015
+ kind: "image-ref",
4016
+ bytes,
4017
+ mimeType,
4018
+ config: options.config
4019
+ });
4020
+ assets.push(entry);
4021
+ }
4022
+ const exportIds = options.exportNodeIds.map((item) => item.nodeId).filter((id) => !assets.some((asset) => asset.nodeId === id));
4023
+ if (!exportIds.length) {
4024
+ return assets;
4025
+ }
4026
+ const exportUrls = await fetchFigmaNodeExports(options.fileKey, options.accessToken, exportIds.slice(0, 30));
4027
+ for (const item of options.exportNodeIds) {
4028
+ const url = exportUrls[item.nodeId];
4029
+ if (!url) {
4030
+ continue;
4031
+ }
4032
+ const { bytes, mimeType } = await downloadBinary(url);
4033
+ const ext = extensionForMime(mimeType);
4034
+ const relativePath = figmaAssetFileRelative(options.changeId, options.fileKey, item.nodeId, ext);
4035
+ const entry = await persistAsset({
4036
+ projectRoot: options.projectRoot,
4037
+ relativePath,
4038
+ assetId: item.nodeId,
4039
+ name: item.name,
4040
+ nodeId: item.nodeId,
4041
+ kind: "node-export",
4042
+ bytes,
4043
+ mimeType,
4044
+ config: options.config
4045
+ });
4046
+ assets.push(entry);
4047
+ }
4048
+ return assets;
4049
+ }
4050
+ async function persistAsset(options) {
4051
+ const absolutePath = join19(options.projectRoot, options.relativePath);
4052
+ await mkdir6(join19(absolutePath, ".."), { recursive: true });
4053
+ if (options.bytes.byteLength <= options.config.inlineImageMaxBytes) {
4054
+ const inlinePath = `${options.relativePath}.inline.json`;
4055
+ await writeFile(
4056
+ join19(options.projectRoot, inlinePath),
4057
+ JSON.stringify({
4058
+ mimeType: options.mimeType,
4059
+ base64: toBase64(options.bytes)
4060
+ }),
4061
+ "utf8"
4062
+ );
4063
+ return {
4064
+ id: options.assetId,
4065
+ kind: options.kind,
4066
+ name: options.name,
4067
+ nodeId: options.nodeId,
4068
+ imageRef: options.imageRef,
4069
+ storage: "inline",
4070
+ relativePath: inlinePath,
4071
+ mimeType: options.mimeType,
4072
+ bytes: options.bytes.byteLength
4073
+ };
4074
+ }
4075
+ await writeFile(absolutePath, options.bytes);
4076
+ return {
4077
+ id: options.assetId,
4078
+ kind: options.kind,
4079
+ name: options.name,
4080
+ nodeId: options.nodeId,
4081
+ imageRef: options.imageRef,
4082
+ storage: "file",
4083
+ relativePath: options.relativePath,
4084
+ mimeType: options.mimeType,
4085
+ bytes: options.bytes.byteLength
4086
+ };
4087
+ }
4088
+
4089
+ // src/figma/manifest.ts
4090
+ import { readFile as readFile13 } from "fs/promises";
4091
+ import { join as join20 } from "path";
4092
+ import { parseDocument as parseDocument4, stringify as stringify3 } from "yaml";
4093
+ async function readFigmaDesignManifest(projectRoot, changeId) {
4094
+ try {
4095
+ const raw = await readFile13(join20(projectRoot, figmaDesignManifestRelativePath2(changeId)), "utf8");
4096
+ const doc = parseDocument4(raw).toJSON();
4097
+ if (!doc?.schemaVersion) {
4098
+ return null;
4099
+ }
4100
+ return doc;
4101
+ } catch {
4102
+ return null;
4103
+ }
4104
+ }
4105
+ function renderFigmaDesignManifestYaml(manifest) {
4106
+ return stringify3(manifest);
4107
+ }
4108
+
4109
+ // src/templates/figma-sync.ts
4110
+ function renderFigmaSyncInstructions(options) {
4111
+ const title = options.language === "en" ? "Figma sync (this change)" : "Figma \u540C\u6B65\uFF08\u672C change\uFF09";
4112
+ const intro = options.language === "en" ? `FET synced **${options.nodeCount}** nodes and **${options.assetCount}** assets (${options.fileAssetCount} saved as files on disk). Use this data before UI implementation\u2014do not guess visuals.` : `FET \u5DF2\u540C\u6B65 **${options.nodeCount}** \u4E2A\u8282\u70B9\u3001**${options.assetCount}** \u4E2A\u7D20\u6750\uFF08\u5176\u4E2D **${options.fileAssetCount}** \u4E2A\u5927\u56FE\u5DF2\u843D\u76D8\uFF09\u3002\u5B9E\u65BD UI \u524D\u5FC5\u987B\u57FA\u4E8E\u8FD9\u4E9B\u6570\u636E\uFF0C\u7981\u6B62\u731C\u6D4B\u89C6\u89C9\u6548\u679C\u3002`;
4113
+ const readSteps = options.language === "en" ? `## Required reading order (before UI code)
4114
+
4115
+ 1. \`${options.manifestPath}\` \u2014 node summaries, colors, typography, asset paths.
4116
+ 2. \`${options.nodesPath}\` \u2014 full Figma node tree (JSON) for layout and nested structure.
4117
+ 3. \`openspec/changes/${options.changeId}/.fet/figma-assets/\` \u2014 background images and exports (use file paths in code, not base64 for large assets).
4118
+ 4. \`figma-apply-instructions.md\` and \`figma-stop.md\` in the same folder \u2014 guard rules.
4119
+
4120
+ ## Asset usage in code
4121
+
4122
+ - **storage: file** \u2192 import or reference the \`relativePath\` under the project root (e.g. \`public/\` or \`src/assets/\` after copy).
4123
+ - **storage: inline** \u2192 small icons only; read \`.inline.json\` if needed.
4124
+ - Never embed multi\u2011hundred\u2011KB images as base64 in source.` : `## \u5B9E\u65BD UI \u524D\u5FC5\u8BFB\uFF08\u987A\u5E8F\uFF09
4125
+
4126
+ 1. \`${options.manifestPath}\` \u2014 \u8282\u70B9\u6458\u8981\u3001\u989C\u8272\u3001\u5B57\u53F7\u3001\u7D20\u6750\u8DEF\u5F84\u3002
4127
+ 2. \`${options.nodesPath}\` \u2014 \u5B8C\u6574 Figma \u8282\u70B9\u6811\uFF08JSON\uFF09\uFF0C\u7528\u4E8E\u5E03\u5C40\u4E0E\u5D4C\u5957\u7ED3\u6784\u3002
4128
+ 3. \`openspec/changes/${options.changeId}/.fet/figma-assets/\` \u2014 \u80CC\u666F\u56FE\u4E0E\u5BFC\u51FA\u56FE\uFF08\u4EE3\u7801\u4E2D\u7528\u6587\u4EF6\u8DEF\u5F84\uFF0C\u5927\u56FE\u7981\u6B62 base64\uFF09\u3002
4129
+ 4. \u540C\u76EE\u5F55 \`figma-apply-instructions.md\`\u3001\`figma-stop.md\` \u2014 \u5B88\u536B\u89C4\u5219\u3002
4130
+
4131
+ ## \u4EE3\u7801\u4E2D\u5982\u4F55\u4F7F\u7528\u7D20\u6750
4132
+
4133
+ - **storage: file** \u2192 \u5C06 \`relativePath\` \u590D\u5236\u5230 \`public/\` \u6216 \`src/assets/\` \u540E\u5F15\u7528\u3002
4134
+ - **storage: inline** \u2192 \u4EC5\u7528\u4E8E\u5C0F\u56FE\u6807\uFF1B\u5FC5\u8981\u65F6\u8BFB\u53D6 \`.inline.json\`\u3002
4135
+ - \u7981\u6B62\u5728\u6E90\u7801\u4E2D\u5185\u8054\u6570\u767E KB \u4EE5\u4E0A\u7684 base64 \u56FE\u7247\u3002`;
4136
+ return `---
4137
+ schemaVersion: 1
4138
+ fetVersion: ${FET_VERSION}
4139
+ generatedAt: ${options.generatedAt}
4140
+ changeId: ${options.changeId}
4141
+ purpose: figma-sync
4142
+ ---
4143
+
4144
+ # ${title}
4145
+
4146
+ ${intro}
4147
+
4148
+ ${readSteps}
4149
+ `;
4150
+ }
4151
+ function renderFigmaSyncNextSteps(changeId, language, apiKeyEnv) {
4152
+ if (language === "en") {
4153
+ return [
4154
+ `Run fet figma sync --change ${changeId} (set ${apiKeyEnv}) to fetch all Figma nodes and download assets before UI work.`,
4155
+ `Read openspec/changes/${changeId}/.fet/figma-sync-instructions.md and figma-design-manifest.yaml after sync.`
4156
+ ];
4157
+ }
4158
+ return [
4159
+ `\u5B9E\u65BD UI \u524D\u6267\u884C fet figma sync --change ${changeId}\uFF08\u914D\u7F6E ${apiKeyEnv}\uFF09\uFF0C\u62C9\u53D6\u5168\u90E8\u8282\u70B9\u5E76\u4E0B\u8F7D\u7D20\u6750\u3002`,
4160
+ `\u540C\u6B65\u540E\u9605\u8BFB openspec/changes/${changeId}/.fet/figma-sync-instructions.md \u4E0E figma-design-manifest.yaml\u3002`
4161
+ ];
4162
+ }
4163
+
4164
+ // src/figma/url.ts
4165
+ var FILE_KEY_PATTERN = /figma\.com\/(?:file|design|proto)\/([a-zA-Z0-9]+)/i;
4166
+ var NODE_ID_PATTERN = /[?&]node-id=([\d]+)-([\d]+)/i;
4167
+ function parseFigmaUrl(url) {
4168
+ const fileMatch = url.match(FILE_KEY_PATTERN);
4169
+ if (!fileMatch?.[1]) {
4170
+ return null;
4171
+ }
4172
+ const nodeMatch = url.match(NODE_ID_PATTERN);
4173
+ return {
4174
+ fileKey: fileMatch[1],
4175
+ nodeId: nodeMatch ? `${nodeMatch[1]}:${nodeMatch[2]}` : void 0,
4176
+ url
4177
+ };
4178
+ }
4179
+ function parseFigmaUrls(urls) {
4180
+ const seen = /* @__PURE__ */ new Set();
4181
+ const parsed = [];
4182
+ for (const url of urls) {
4183
+ const item = parseFigmaUrl(url);
4184
+ if (!item) {
4185
+ continue;
4186
+ }
4187
+ const key = `${item.fileKey}:${item.nodeId ?? ""}`;
4188
+ if (seen.has(key)) {
4189
+ continue;
4190
+ }
4191
+ seen.add(key);
4192
+ parsed.push(item);
4193
+ }
4194
+ return parsed;
4195
+ }
4196
+
4197
+ // src/figma/walk.ts
4198
+ function rgbaFill(fill) {
4199
+ if (fill.type !== "SOLID" || !fill.color) {
4200
+ return null;
4201
+ }
4202
+ const { r, g, b, a = 1 } = fill.color;
4203
+ const alpha = Math.round(a * 100) / 100;
4204
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`;
4205
+ }
4206
+ function summarizeFills(fills) {
4207
+ if (!fills?.length) {
4208
+ return void 0;
4209
+ }
4210
+ const out = [];
4211
+ for (const fill of fills) {
4212
+ if (fill.visible === false) {
4213
+ continue;
4214
+ }
4215
+ if (fill.type === "IMAGE" && fill.imageRef) {
4216
+ out.push(`image:${fill.imageRef}`);
4217
+ } else {
4218
+ const color = rgbaFill(fill);
4219
+ if (color) {
4220
+ out.push(color);
4221
+ }
4222
+ }
4223
+ }
4224
+ return out.length ? out : void 0;
4225
+ }
4226
+ function summarizeNode(node) {
4227
+ const summary = {
4228
+ id: node.id,
4229
+ name: node.name,
4230
+ type: node.type,
4231
+ visible: node.visible !== false
4232
+ };
4233
+ if (node.absoluteBoundingBox) {
4234
+ summary.width = node.absoluteBoundingBox.width;
4235
+ summary.height = node.absoluteBoundingBox.height;
4236
+ }
4237
+ if (node.layoutMode) {
4238
+ summary.layoutMode = node.layoutMode;
4239
+ }
4240
+ const fills = summarizeFills(node.fills);
4241
+ if (fills) {
4242
+ summary.fills = fills;
4243
+ }
4244
+ if (node.characters) {
4245
+ summary.characters = node.characters.slice(0, 200);
4246
+ }
4247
+ if (node.style) {
4248
+ summary.textStyle = {
4249
+ fontFamily: node.style.fontFamily,
4250
+ fontSize: node.style.fontSize,
4251
+ fontWeight: node.style.fontWeight,
4252
+ lineHeight: node.style.lineHeightPx
4253
+ };
4254
+ }
4255
+ if (node.children?.length) {
4256
+ summary.children = node.children.map((child) => child.id);
4257
+ }
4258
+ return summary;
4259
+ }
4260
+ function walkFigmaDocument(root) {
4261
+ const nodes = [];
4262
+ const imageRefs = [];
4263
+ const exportNodeIds = [];
4264
+ const seenRefs = /* @__PURE__ */ new Set();
4265
+ function visit(node) {
4266
+ nodes.push(summarizeNode(node));
4267
+ for (const fill of node.fills ?? []) {
4268
+ if (fill.type === "IMAGE" && fill.imageRef && fill.visible !== false && !seenRefs.has(fill.imageRef)) {
4269
+ seenRefs.add(fill.imageRef);
4270
+ imageRefs.push({ ref: fill.imageRef, nodeId: node.id, name: node.name });
4271
+ }
4272
+ }
4273
+ const hasImageFill = node.fills?.some((fill) => fill.type === "IMAGE" && fill.visible !== false);
4274
+ if (hasImageFill && (node.type === "RECTANGLE" || node.type === "FRAME" || node.type === "COMPONENT" || node.type === "INSTANCE")) {
4275
+ exportNodeIds.push({ nodeId: node.id, name: node.name });
4276
+ }
4277
+ for (const child of node.children ?? []) {
4278
+ visit(child);
4279
+ }
4280
+ }
4281
+ visit(root);
4282
+ return { nodes, imageRefs, exportNodeIds };
4283
+ }
4284
+
4285
+ // src/figma/sync.ts
4286
+ async function syncFigmaAssets(options) {
4287
+ const syncConfig = await loadFigmaSyncConfig(options.projectRoot);
4288
+ if (!syncConfig.enabled) {
4289
+ return null;
4290
+ }
4291
+ const { urls, sources } = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4292
+ if (!urls.length) {
4293
+ throw new FetError({
4294
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4295
+ message: "No Figma URLs found in this change.",
4296
+ details: { changeId: options.changeId },
4297
+ recoverable: true,
4298
+ suggestedCommand: `Add a Figma link to openspec/changes/${options.changeId}/proposal.md or design.md`
4299
+ });
4300
+ }
4301
+ const token = resolveFigmaAccessToken(syncConfig, options.accessToken);
4302
+ if (!token) {
4303
+ throw new FetError({
4304
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4305
+ message: `Figma access token missing. Set ${syncConfig.apiKeyEnv} or pass --token.`,
4306
+ details: { apiKeyEnv: syncConfig.apiKeyEnv },
4307
+ recoverable: true,
4308
+ suggestedCommand: `FIGMA_ACCESS_TOKEN=*** fet figma sync --change ${options.changeId}`
4309
+ });
4310
+ }
4311
+ const parsed = parseFigmaUrls(urls);
4312
+ if (!parsed.length) {
4313
+ throw new FetError({
4314
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4315
+ message: "Could not parse any Figma file keys from the detected URLs.",
4316
+ details: { urls },
4317
+ recoverable: true
4318
+ });
4319
+ }
4320
+ const planningFingerprint = await computePlanningFingerprint(options.projectRoot, options.changeId);
4321
+ const existing = await readFigmaDesignManifest(options.projectRoot, options.changeId);
4322
+ if (!options.force && existing && existing.planningFingerprint === planningFingerprint && existing.figmaUrls.join(",") === urls.join(",")) {
4323
+ return {
4324
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4325
+ nodesPath: figmaNodesRelativePath(options.changeId),
4326
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4327
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4328
+ manifest: existing,
4329
+ written: false
4330
+ };
4331
+ }
4332
+ if (options.plan) {
4333
+ return {
4334
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4335
+ nodesPath: figmaNodesRelativePath(options.changeId),
4336
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4337
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4338
+ manifest: {
4339
+ schemaVersion: 1,
4340
+ fetVersion: FET_VERSION,
4341
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4342
+ changeId: options.changeId,
4343
+ planningFingerprint,
4344
+ figmaUrls: urls,
4345
+ figmaSources: sources,
4346
+ files: parsed.map((item) => ({
4347
+ fileKey: item.fileKey,
4348
+ url: item.url,
4349
+ rootNodeId: item.nodeId,
4350
+ nodeCount: 0,
4351
+ assetCount: 0
4352
+ })),
4353
+ nodes: [],
4354
+ assets: []
4355
+ },
4356
+ written: false
4357
+ };
4358
+ }
4359
+ const allNodes = [];
4360
+ const allAssets = [];
4361
+ const files = [];
4362
+ const nodesByFile = {};
4363
+ for (const item of parsed) {
4364
+ const file = await fetchFigmaFile(item.fileKey, token, item.nodeId ? [item.nodeId] : void 0);
4365
+ const walked = walkFigmaDocument(file.document);
4366
+ allNodes.push(...walked.nodes);
4367
+ nodesByFile[item.fileKey] = {
4368
+ name: file.name,
4369
+ rootNodeId: item.nodeId ?? file.document.id,
4370
+ document: file.document,
4371
+ components: file.components ?? {},
4372
+ styles: file.styles ?? {}
4373
+ };
4374
+ const assets = await downloadFigmaAssets({
4375
+ projectRoot: options.projectRoot,
4376
+ changeId: options.changeId,
4377
+ fileKey: item.fileKey,
4378
+ accessToken: token,
4379
+ config: syncConfig,
4380
+ imageRefs: walked.imageRefs,
4381
+ exportNodeIds: walked.exportNodeIds.slice(0, 20)
4382
+ });
4383
+ allAssets.push(...assets);
4384
+ files.push({
4385
+ fileKey: item.fileKey,
4386
+ url: item.url,
4387
+ rootNodeId: item.nodeId,
4388
+ nodeCount: walked.nodes.length,
4389
+ assetCount: assets.length
4390
+ });
4391
+ }
4392
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
4393
+ const manifest = {
4394
+ schemaVersion: 1,
4395
+ fetVersion: FET_VERSION,
4396
+ generatedAt,
4397
+ changeId: options.changeId,
4398
+ planningFingerprint,
4399
+ figmaUrls: urls,
4400
+ figmaSources: sources,
4401
+ files,
4402
+ nodes: allNodes,
4403
+ assets: allAssets
4404
+ };
4405
+ const fetDir = join21(options.projectRoot, "openspec", "changes", options.changeId, ".fet");
4406
+ await mkdir7(fetDir, { recursive: true });
4407
+ const manifestPath3 = figmaDesignManifestRelativePath2(options.changeId);
4408
+ const nodesPath = figmaNodesRelativePath(options.changeId);
4409
+ const instructionsPath = figmaSyncInstructionsRelativePath2(options.changeId);
4410
+ await atomicWrite(join21(options.projectRoot, manifestPath3), renderFigmaDesignManifestYaml(manifest));
4411
+ await writeFile2(join21(options.projectRoot, nodesPath), JSON.stringify(nodesByFile, null, 2), "utf8");
4412
+ await atomicWrite(
4413
+ join21(options.projectRoot, instructionsPath),
4414
+ renderFigmaSyncInstructions({
4415
+ changeId: options.changeId,
4416
+ generatedAt,
4417
+ manifestPath: manifestPath3,
4418
+ nodesPath,
4419
+ language: options.language,
4420
+ nodeCount: allNodes.length,
4421
+ assetCount: allAssets.length,
4422
+ fileAssetCount: allAssets.filter((asset) => asset.storage === "file").length
4423
+ })
4424
+ );
4425
+ return {
4426
+ manifestPath: manifestPath3,
4427
+ nodesPath,
4428
+ instructionsPath,
4429
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4430
+ manifest,
4431
+ written: true
4432
+ };
4433
+ }
4434
+ async function tryAutoSyncFigmaAssets(options) {
4435
+ const syncConfig = await loadFigmaSyncConfig(options.projectRoot);
4436
+ if (!syncConfig.enabled) {
4437
+ return { synced: false, skipped: true, missingToken: false };
4438
+ }
4439
+ const { urls } = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4440
+ if (!urls.length) {
4441
+ return { synced: false, skipped: true, missingToken: false };
4442
+ }
4443
+ const token = resolveFigmaAccessToken(syncConfig);
4444
+ if (!token) {
4445
+ return { synced: false, skipped: false, missingToken: true };
4446
+ }
4447
+ const existing = await readFigmaDesignManifest(options.projectRoot, options.changeId);
4448
+ const planningFingerprint = await computePlanningFingerprint(options.projectRoot, options.changeId);
4449
+ if (existing && existing.planningFingerprint === planningFingerprint && existing.figmaUrls.join(",") === urls.join(",")) {
4450
+ return {
4451
+ synced: false,
4452
+ skipped: true,
4453
+ missingToken: false,
4454
+ result: {
4455
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4456
+ nodesPath: figmaNodesRelativePath(options.changeId),
4457
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4458
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4459
+ manifest: existing,
4460
+ written: false
4461
+ }
4462
+ };
4463
+ }
4464
+ const result = await syncFigmaAssets({
4465
+ projectRoot: options.projectRoot,
4466
+ changeId: options.changeId,
4467
+ language: options.language,
4468
+ accessToken: token
4469
+ });
4470
+ return { synced: Boolean(result?.written), skipped: false, missingToken: false, result: result ?? void 0 };
4471
+ }
4472
+
3704
4473
  // src/ui-display-contract.ts
3705
4474
  import { existsSync as existsSync2 } from "fs";
3706
- import { readdir as readdir4, readFile as readFile11, stat as stat8 } from "fs/promises";
3707
- import { join as join17, relative as relative3 } from "path";
3708
- import { parse as parse3, parseDocument as parseDocument3 } from "yaml";
4475
+ import { readdir as readdir5, readFile as readFile14, stat as stat9 } from "fs/promises";
4476
+ import { join as join22, relative as relative4 } from "path";
4477
+ import { parse as parse3, parseDocument as parseDocument5 } from "yaml";
3709
4478
  var DEFAULT_CONFIG2 = {
3710
4479
  enabled: true
3711
4480
  };
@@ -3715,8 +4484,8 @@ var BACKTICK_PATH_PATTERN = /`([^`]+\.(?:ya?ml|json))`/gi;
3715
4484
  var OPENAPI_BARE_PATTERN = /\b(openapi\.ya?ml|swagger\.ya?ml|swagger\.json)\b/gi;
3716
4485
  async function loadUiDisplayContractConfig(projectRoot) {
3717
4486
  try {
3718
- const raw = await readFile11(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3719
- const doc = parseDocument3(raw);
4487
+ const raw = await readFile14(join22(projectRoot, "openspec", "config.yaml"), "utf8");
4488
+ const doc = parseDocument5(raw);
3720
4489
  const fetNode = doc.get("fet", true);
3721
4490
  const node = fetNode?.get?.("uiDisplayContract");
3722
4491
  if (!node || typeof node.get !== "function") {
@@ -3731,12 +4500,12 @@ async function loadUiDisplayContractConfig(projectRoot) {
3731
4500
  }
3732
4501
  }
3733
4502
  async function collectApiSourcesFromChange(projectRoot, changeId) {
3734
- const changePath = join17(projectRoot, "openspec", "changes", changeId);
4503
+ const changePath = join22(projectRoot, "openspec", "changes", changeId);
3735
4504
  const paths = /* @__PURE__ */ new Set();
3736
4505
  const sources = [];
3737
4506
  const candidates = ["proposal.md", "tasks.md", "design.md"];
3738
4507
  for (const name of candidates) {
3739
- const filePath = join17(changePath, name);
4508
+ const filePath = join22(changePath, name);
3740
4509
  const content = await readOptional4(filePath);
3741
4510
  if (!content) {
3742
4511
  continue;
@@ -3749,7 +4518,7 @@ async function collectApiSourcesFromChange(projectRoot, changeId) {
3749
4518
  }
3750
4519
  }
3751
4520
  }
3752
- const specsPath = join17(changePath, "specs");
4521
+ const specsPath = join22(changePath, "specs");
3753
4522
  for (const filePath of await listMarkdownFiles2(specsPath)) {
3754
4523
  const content = await readOptional4(filePath);
3755
4524
  if (!content) {
@@ -3757,7 +4526,7 @@ async function collectApiSourcesFromChange(projectRoot, changeId) {
3757
4526
  }
3758
4527
  const found = extractApiDocPaths(content, projectRoot);
3759
4528
  if (found.length) {
3760
- sources.push(relative3(projectRoot, filePath).replaceAll("\\", "/"));
4529
+ sources.push(relative4(projectRoot, filePath).replaceAll("\\", "/"));
3761
4530
  for (const path of found) {
3762
4531
  paths.add(path);
3763
4532
  }
@@ -3783,7 +4552,7 @@ function extractApiDocPaths(content, projectRoot) {
3783
4552
  const common = ["openapi.yaml", "openapi.yml", "docs/openapi.yaml", "swagger.yaml", "swagger.json"];
3784
4553
  for (const candidate of common) {
3785
4554
  const normalized = normalizeRepoPath(candidate, projectRoot);
3786
- if (normalized && existsSync2(join17(projectRoot, normalized))) {
4555
+ if (normalized && existsSync2(join22(projectRoot, normalized))) {
3787
4556
  found.add(normalized);
3788
4557
  }
3789
4558
  }
@@ -3793,7 +4562,7 @@ function extractApiDocPaths(content, projectRoot) {
3793
4562
  async function extractOpenApiSchemas(projectRoot, relativePaths) {
3794
4563
  const schemas = [];
3795
4564
  for (const rel of relativePaths) {
3796
- const absolute = join17(projectRoot, rel);
4565
+ const absolute = join22(projectRoot, rel);
3797
4566
  const content = await readOptional4(absolute);
3798
4567
  if (!content) {
3799
4568
  continue;
@@ -3869,9 +4638,9 @@ async function ensureChangeUiDisplayContract(options) {
3869
4638
  if (!config.enabled) {
3870
4639
  return null;
3871
4640
  }
3872
- const figma = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4641
+ const figma2 = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
3873
4642
  const api = await collectApiSourcesFromChange(options.projectRoot, options.changeId);
3874
- const hasFigma = figma.urls.length > 0;
4643
+ const hasFigma = figma2.urls.length > 0;
3875
4644
  const hasApi = api.paths.length > 0;
3876
4645
  if (!hasFigma && !hasApi) {
3877
4646
  return null;
@@ -3884,14 +4653,14 @@ async function ensureChangeUiDisplayContract(options) {
3884
4653
  changeId: options.changeId,
3885
4654
  generatedAt,
3886
4655
  fetVersion: options.fetVersion,
3887
- figmaUrls: figma.urls,
3888
- figmaSources: figma.sources,
4656
+ figmaUrls: figma2.urls,
4657
+ figmaSources: figma2.sources,
3889
4658
  apiPaths: api.paths,
3890
4659
  apiSources: api.sources,
3891
4660
  apiSchemas
3892
4661
  });
3893
4662
  const contractContent = renderUiDisplayContractYaml(doc);
3894
- const contractAbsolutePath = join17(options.projectRoot, contractRelativePath);
4663
+ const contractAbsolutePath = join22(options.projectRoot, contractRelativePath);
3895
4664
  const existingContract = await readOptional4(contractAbsolutePath);
3896
4665
  let written = false;
3897
4666
  if (existingContract !== contractContent) {
@@ -3906,7 +4675,7 @@ async function ensureChangeUiDisplayContract(options) {
3906
4675
  hasFigma,
3907
4676
  hasApi
3908
4677
  });
3909
- const applyAbsolutePath = join17(options.projectRoot, applyRelativePath);
4678
+ const applyAbsolutePath = join22(options.projectRoot, applyRelativePath);
3910
4679
  const existingApply = await readOptional4(applyAbsolutePath);
3911
4680
  if (existingApply !== applyContent) {
3912
4681
  await atomicWrite(applyAbsolutePath, applyContent);
@@ -3973,9 +4742,9 @@ async function listMarkdownFiles2(root) {
3973
4742
  }
3974
4743
  async function walk2(dir, files) {
3975
4744
  try {
3976
- const entries = await readdir4(dir, { withFileTypes: true });
4745
+ const entries = await readdir5(dir, { withFileTypes: true });
3977
4746
  for (const entry of entries) {
3978
- const fullPath = join17(dir, entry.name);
4747
+ const fullPath = join22(dir, entry.name);
3979
4748
  if (entry.isDirectory()) {
3980
4749
  await walk2(fullPath, files);
3981
4750
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -3988,8 +4757,8 @@ async function walk2(dir, files) {
3988
4757
  }
3989
4758
  async function readOptional4(path) {
3990
4759
  try {
3991
- await stat8(path);
3992
- return await readFile11(path, "utf8");
4760
+ await stat9(path);
4761
+ return await readFile14(path, "utf8");
3993
4762
  } catch {
3994
4763
  return null;
3995
4764
  }
@@ -4034,9 +4803,9 @@ async function assertChangeExists(ctx, changeId) {
4034
4803
  }
4035
4804
 
4036
4805
  // src/tdd/config.ts
4037
- import { readFile as readFile12 } from "fs/promises";
4038
- import { join as join18 } from "path";
4039
- import { parseDocument as parseDocument4 } from "yaml";
4806
+ import { readFile as readFile15 } from "fs/promises";
4807
+ import { join as join23 } from "path";
4808
+ import { parseDocument as parseDocument6 } from "yaml";
4040
4809
  var DEFAULT_CONFIG3 = {
4041
4810
  enabled: true,
4042
4811
  mode: "require_before_apply",
@@ -4044,8 +4813,8 @@ var DEFAULT_CONFIG3 = {
4044
4813
  };
4045
4814
  async function loadTddConfig(projectRoot) {
4046
4815
  try {
4047
- const raw = await readFile12(join18(projectRoot, "openspec", "config.yaml"), "utf8");
4048
- const doc = parseDocument4(raw);
4816
+ const raw = await readFile15(join23(projectRoot, "openspec", "config.yaml"), "utf8");
4817
+ const doc = parseDocument6(raw);
4049
4818
  const fetNode = doc.get("fet", true);
4050
4819
  const node = fetNode?.get?.("tdd");
4051
4820
  if (!node || typeof node.get !== "function") {
@@ -4079,70 +4848,12 @@ function isTddRequired(config) {
4079
4848
  return config.enabled && config.mode === "require_before_apply";
4080
4849
  }
4081
4850
 
4082
- // src/tdd/fingerprint.ts
4083
- import { createHash } from "crypto";
4084
- import { readdir as readdir5, readFile as readFile13, stat as stat9 } from "fs/promises";
4085
- import { join as join19, relative as relative4 } from "path";
4086
- async function collectPlanningSources(projectRoot, changeId) {
4087
- const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
4088
- const sources = [];
4089
- const rootFiles = ["proposal.md", "tasks.md", "design.md"];
4090
- for (const name of rootFiles) {
4091
- const path = join19(changeRoot, name);
4092
- if (await exists5(path)) {
4093
- sources.push(relative4(projectRoot, path).replace(/\\/g, "/"));
4094
- }
4095
- }
4096
- const specsDir = join19(changeRoot, "specs");
4097
- if (await exists5(specsDir)) {
4098
- for (const file of await walkFiles(specsDir)) {
4099
- if (file.endsWith(".md")) {
4100
- sources.push(relative4(projectRoot, file).replace(/\\/g, "/"));
4101
- }
4102
- }
4103
- }
4104
- return sources.sort();
4105
- }
4106
- async function computePlanningFingerprint(projectRoot, changeId) {
4107
- const sources = await collectPlanningSources(projectRoot, changeId);
4108
- const hash = createHash("sha256");
4109
- for (const source of sources) {
4110
- const content = await readFile13(join19(projectRoot, source), "utf8");
4111
- hash.update(source);
4112
- hash.update("\0");
4113
- hash.update(content);
4114
- hash.update("\0");
4115
- }
4116
- return `sha256:${hash.digest("hex")}`;
4117
- }
4118
- async function walkFiles(dir) {
4119
- const entries = await readdir5(dir, { withFileTypes: true });
4120
- const files = [];
4121
- for (const entry of entries) {
4122
- const path = join19(dir, entry.name);
4123
- if (entry.isDirectory()) {
4124
- files.push(...await walkFiles(path));
4125
- } else if (entry.isFile()) {
4126
- files.push(path);
4127
- }
4128
- }
4129
- return files;
4130
- }
4131
- async function exists5(path) {
4132
- try {
4133
- await stat9(path);
4134
- return true;
4135
- } catch {
4136
- return false;
4137
- }
4138
- }
4139
-
4140
4851
  // src/tdd/manifest.ts
4141
- import { mkdir as mkdir6, readFile as readFile14, stat as stat10 } from "fs/promises";
4142
- import { dirname as dirname8, join as join20 } from "path";
4143
- import { parse as parse4, stringify as stringify3 } from "yaml";
4852
+ import { mkdir as mkdir8, readFile as readFile16, stat as stat10 } from "fs/promises";
4853
+ import { dirname as dirname8, join as join24 } from "path";
4854
+ import { parse as parse4, stringify as stringify4 } from "yaml";
4144
4855
  function tddManifestPath(projectRoot, changeId) {
4145
- return join20(projectRoot, tddManifestRelativePath(changeId));
4856
+ return join24(projectRoot, tddManifestRelativePath(changeId));
4146
4857
  }
4147
4858
  async function readTddManifest(projectRoot, changeId) {
4148
4859
  const path = tddManifestPath(projectRoot, changeId);
@@ -4151,7 +4862,7 @@ async function readTddManifest(projectRoot, changeId) {
4151
4862
  } catch {
4152
4863
  return null;
4153
4864
  }
4154
- const doc = parse4(await readFile14(path, "utf8"));
4865
+ const doc = parse4(await readFile16(path, "utf8"));
4155
4866
  if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
4156
4867
  return null;
4157
4868
  }
@@ -4159,15 +4870,15 @@ async function readTddManifest(projectRoot, changeId) {
4159
4870
  }
4160
4871
  async function writeTddManifest(projectRoot, manifest) {
4161
4872
  const relative6 = tddManifestRelativePath(manifest.changeId);
4162
- const path = join20(projectRoot, relative6);
4163
- await mkdir6(dirname8(path), { recursive: true });
4164
- await atomicWrite(path, stringify3(manifest));
4873
+ const path = join24(projectRoot, relative6);
4874
+ await mkdir8(dirname8(path), { recursive: true });
4875
+ await atomicWrite(path, stringify4(manifest));
4165
4876
  return relative6;
4166
4877
  }
4167
4878
  async function writeTddResults(projectRoot, results) {
4168
4879
  const relative6 = tddResultsRelativePath(results.changeId);
4169
- const path = join20(projectRoot, relative6);
4170
- await mkdir6(dirname8(path), { recursive: true });
4880
+ const path = join24(projectRoot, relative6);
4881
+ await mkdir8(dirname8(path), { recursive: true });
4171
4882
  await atomicWrite(path, `${JSON.stringify(results, null, 2)}
4172
4883
  `);
4173
4884
  return relative6;
@@ -4275,8 +4986,8 @@ async function git(cwd, args) {
4275
4986
  }
4276
4987
 
4277
4988
  // src/state/store.ts
4278
- import { mkdir as mkdir7, readFile as readFile15 } from "fs/promises";
4279
- import { join as join21 } from "path";
4989
+ import { mkdir as mkdir9, readFile as readFile17 } from "fs/promises";
4990
+ import { join as join25 } from "path";
4280
4991
 
4281
4992
  // src/language.ts
4282
4993
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -4398,7 +5109,7 @@ var StateStore = class {
4398
5109
  project;
4399
5110
  async readGlobal() {
4400
5111
  try {
4401
- const value = JSON.parse(await readFile15(this.globalPath(), "utf8"));
5112
+ const value = JSON.parse(await readFile17(this.globalPath(), "utf8"));
4402
5113
  assertGlobalState(value);
4403
5114
  return value;
4404
5115
  } catch (error) {
@@ -4413,13 +5124,13 @@ var StateStore = class {
4413
5124
  }
4414
5125
  async writeGlobal(state) {
4415
5126
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4416
- await mkdir7(join21(this.projectRoot, "openspec"), { recursive: true });
5127
+ await mkdir9(join25(this.projectRoot, "openspec"), { recursive: true });
4417
5128
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
4418
5129
  `);
4419
5130
  }
4420
5131
  async readChange(changeId) {
4421
5132
  try {
4422
- const value = JSON.parse(await readFile15(this.changePath(changeId), "utf8"));
5133
+ const value = JSON.parse(await readFile17(this.changePath(changeId), "utf8"));
4423
5134
  assertChangeState(value);
4424
5135
  return value;
4425
5136
  } catch (error) {
@@ -4434,15 +5145,15 @@ var StateStore = class {
4434
5145
  }
4435
5146
  async writeChange(state) {
4436
5147
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4437
- await mkdir7(join21(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
5148
+ await mkdir9(join25(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
4438
5149
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
4439
5150
  `);
4440
5151
  }
4441
5152
  globalPath() {
4442
- return join21(this.projectRoot, "openspec", "fet-state.json");
5153
+ return join25(this.projectRoot, "openspec", "fet-state.json");
4443
5154
  }
4444
5155
  changePath(changeId) {
4445
- return join21(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
5156
+ return join25(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
4446
5157
  }
4447
5158
  };
4448
5159
  function isNotFound(error) {
@@ -4450,11 +5161,11 @@ function isNotFound(error) {
4450
5161
  }
4451
5162
 
4452
5163
  // src/state/tasks.ts
4453
- import { readFile as readFile16 } from "fs/promises";
5164
+ import { readFile as readFile18 } from "fs/promises";
4454
5165
  async function readCompletedTaskIds(tasksPath) {
4455
5166
  let content;
4456
5167
  try {
4457
- content = await readFile16(tasksPath, "utf8");
5168
+ content = await readFile18(tasksPath, "utf8");
4458
5169
  } catch {
4459
5170
  return [];
4460
5171
  }
@@ -4617,6 +5328,8 @@ async function applyWorkflowCommand(ctx, args) {
4617
5328
  fetVersion: ctx.fetVersion
4618
5329
  })
4619
5330
  ]);
5331
+ const figmaSyncConfig = await loadFigmaSyncConfig(ctx.projectRoot);
5332
+ const figmaAutoSync = figmaGuard ? await tryAutoSyncFigmaAssets({ projectRoot: ctx.projectRoot, changeId, language: ctx.language }) : null;
4620
5333
  const applyNextSteps = [
4621
5334
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
4622
5335
  "Implement pending tasks and update task checkboxes only after the work is done.",
@@ -4627,7 +5340,13 @@ async function applyWorkflowCommand(ctx, args) {
4627
5340
  applyNextSteps.unshift(...renderUiDisplayContractApplyNextSteps(changeId, ctx.language));
4628
5341
  }
4629
5342
  if (figmaGuard) {
4630
- applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
5343
+ applyNextSteps.unshift(
5344
+ ...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode, {
5345
+ syncMissingToken: figmaAutoSync?.missingToken,
5346
+ syncDone: figmaAutoSync?.synced || figmaAutoSync?.skipped && Boolean(figmaAutoSync.result),
5347
+ apiKeyEnv: figmaSyncConfig.apiKeyEnv
5348
+ })
5349
+ );
4631
5350
  applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
4632
5351
  }
4633
5352
  ctx.output.result({
@@ -4642,6 +5361,7 @@ async function applyWorkflowCommand(ctx, args) {
4642
5361
  status,
4643
5362
  graphContext: runState.graphContext,
4644
5363
  figmaGuard: figmaGuard ?? void 0,
5364
+ figmaSync: figmaAutoSync ?? void 0,
4645
5365
  uiDisplayContract: uiContract ?? void 0
4646
5366
  }
4647
5367
  });
@@ -5040,7 +5760,7 @@ async function createChangelogEntry(projectRoot, changeId) {
5040
5760
  };
5041
5761
  }
5042
5762
  async function appendChangelog(projectRoot, entry) {
5043
- const changelogPath = join22(projectRoot, "CHANGELOG.md");
5763
+ const changelogPath = join26(projectRoot, "CHANGELOG.md");
5044
5764
  const existing = await readOptional5(changelogPath);
5045
5765
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
5046
5766
  const block = `updateTime: ${entry.updateTime}
@@ -5053,12 +5773,12 @@ ${block}` : block;
5053
5773
  await atomicWrite(changelogPath, next);
5054
5774
  }
5055
5775
  async function readChangeRequirement(projectRoot, changeId) {
5056
- const changeRoot = join22(projectRoot, "openspec", "changes", changeId);
5057
- const proposal = await readOptional5(join22(changeRoot, "proposal.md"));
5776
+ const changeRoot = join26(projectRoot, "openspec", "changes", changeId);
5777
+ const proposal = await readOptional5(join26(changeRoot, "proposal.md"));
5058
5778
  if (proposal) {
5059
5779
  return summarizeMarkdown(proposal);
5060
5780
  }
5061
- const readme = await readOptional5(join22(changeRoot, "README.md"));
5781
+ const readme = await readOptional5(join26(changeRoot, "README.md"));
5062
5782
  if (readme) {
5063
5783
  return summarizeMarkdown(readme);
5064
5784
  }
@@ -5070,7 +5790,7 @@ function summarizeMarkdown(content) {
5070
5790
  }
5071
5791
  async function readOptional5(path) {
5072
5792
  try {
5073
- return await readFile17(path, "utf8");
5793
+ return await readFile19(path, "utf8");
5074
5794
  } catch {
5075
5795
  return null;
5076
5796
  }
@@ -5425,17 +6145,17 @@ async function updateCommand(ctx) {
5425
6145
  }
5426
6146
 
5427
6147
  // src/commands/tdd.ts
5428
- import { mkdir as mkdir8 } from "fs/promises";
5429
- import { join as join24 } from "path";
6148
+ import { mkdir as mkdir10 } from "fs/promises";
6149
+ import { join as join28 } from "path";
5430
6150
 
5431
6151
  // src/tdd/extract-cases.ts
5432
- import { readFile as readFile18 } from "fs/promises";
5433
- import { join as join23 } from "path";
6152
+ import { readFile as readFile20 } from "fs/promises";
6153
+ import { join as join27 } from "path";
5434
6154
  async function extractCasesFromChange(projectRoot, changeId, sources) {
5435
6155
  const cases = [];
5436
6156
  const seen = /* @__PURE__ */ new Set();
5437
6157
  for (const source of sources) {
5438
- const content = await readFile18(join23(projectRoot, source), "utf8");
6158
+ const content = await readFile20(join27(projectRoot, source), "utf8");
5439
6159
  if (source.endsWith("tasks.md")) {
5440
6160
  for (const item of extractTaskCases(content, changeId)) {
5441
6161
  if (!seen.has(item.id)) {
@@ -5569,12 +6289,12 @@ async function tddCommand(ctx) {
5569
6289
  testCommand: testCommand2
5570
6290
  });
5571
6291
  const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
5572
- const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5573
- await mkdir8(fetDir, { recursive: true });
6292
+ const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6293
+ await mkdir10(fetDir, { recursive: true });
5574
6294
  const instructionsPath = tddInstructionsRelativePath(changeId);
5575
6295
  const specPath = tddSpecRelativePath(changeId);
5576
- await atomicWrite(join24(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
5577
- await atomicWrite(join24(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
6296
+ await atomicWrite(join28(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
6297
+ await atomicWrite(join28(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
5578
6298
  const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5579
6299
  changeState.tdd = {
5580
6300
  status: "ready",
@@ -5816,14 +6536,81 @@ function truncate(value, max = 2e3) {
5816
6536
  return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5817
6537
  }
5818
6538
 
6539
+ // src/commands/figma.ts
6540
+ import { mkdir as mkdir11 } from "fs/promises";
6541
+ import { join as join29 } from "path";
6542
+ async function figmaSyncCommand(ctx, options) {
6543
+ await withProjectLock(ctx.projectRoot, { command: "figma", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
6544
+ const changeId = await resolveChangeId(ctx);
6545
+ await assertChangeExists(ctx, changeId);
6546
+ const syncConfig = await loadFigmaSyncConfig(ctx.projectRoot);
6547
+ const result = await syncFigmaAssets({
6548
+ projectRoot: ctx.projectRoot,
6549
+ changeId,
6550
+ language: ctx.language,
6551
+ accessToken: options.token,
6552
+ force: Boolean(options.force),
6553
+ plan: Boolean(options.plan)
6554
+ });
6555
+ if (!result) {
6556
+ ctx.output.result({
6557
+ ok: true,
6558
+ command: "figma",
6559
+ summary: msg(ctx.language, "Figma \u540C\u6B65\u5DF2\u7981\u7528\uFF08fet.figmaGuard.sync.enabled: false\uFF09\u3002", "Figma sync is disabled (fet.figmaGuard.sync.enabled: false)."),
6560
+ data: { changeId, enabled: false }
6561
+ });
6562
+ return;
6563
+ }
6564
+ if (options.plan) {
6565
+ ctx.output.result({
6566
+ ok: true,
6567
+ command: "figma",
6568
+ summary: msg(ctx.language, "Figma \u540C\u6B65\u8BA1\u5212\u5DF2\u751F\u6210\u3002", "Figma sync plan ready."),
6569
+ data: {
6570
+ changeId,
6571
+ files: result.manifest.files,
6572
+ figmaUrls: result.manifest.figmaUrls
6573
+ },
6574
+ nextSteps: [`fet figma sync --change ${changeId}`]
6575
+ });
6576
+ return;
6577
+ }
6578
+ await mkdir11(join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet"), { recursive: true });
6579
+ ctx.output.result({
6580
+ ok: true,
6581
+ command: "figma",
6582
+ summary: result.written ? msg(
6583
+ ctx.language,
6584
+ `\u5DF2\u540C\u6B65 ${result.manifest.nodes.length} \u4E2A\u8282\u70B9\u3001${result.manifest.assets.length} \u4E2A\u7D20\u6750\u3002`,
6585
+ `Synced ${result.manifest.nodes.length} node(s) and ${result.manifest.assets.length} asset(s).`
6586
+ ) : msg(ctx.language, "Figma \u8BBE\u8BA1\u6570\u636E\u5DF2\u662F\u6700\u65B0\uFF0C\u8DF3\u8FC7\u91CD\u65B0\u4E0B\u8F7D\u3002", "Figma design data is up to date; skipped re-download."),
6587
+ data: {
6588
+ changeId,
6589
+ written: result.written,
6590
+ manifestPath: result.manifestPath,
6591
+ nodesPath: result.nodesPath,
6592
+ instructionsPath: result.instructionsPath,
6593
+ assetsDir: result.assetsDir,
6594
+ nodeCount: result.manifest.nodes.length,
6595
+ assetCount: result.manifest.assets.length,
6596
+ fileAssetCount: result.manifest.assets.filter((asset) => asset.storage === "file").length
6597
+ },
6598
+ nextSteps: result.written ? [
6599
+ `Read ${result.instructionsPath} before UI implementation.`,
6600
+ ...renderFigmaSyncNextSteps(changeId, ctx.language, syncConfig.apiKeyEnv).slice(1)
6601
+ ] : [`Read ${result.instructionsPath} before UI implementation.`]
6602
+ });
6603
+ });
6604
+ }
6605
+
5819
6606
  // src/commands/visual.ts
5820
- import { mkdir as mkdir10 } from "fs/promises";
5821
- import { join as join28 } from "path";
6607
+ import { mkdir as mkdir13 } from "fs/promises";
6608
+ import { join as join33 } from "path";
5822
6609
 
5823
6610
  // src/visual/config.ts
5824
- import { readFile as readFile19 } from "fs/promises";
5825
- import { join as join25 } from "path";
5826
- import { parseDocument as parseDocument5 } from "yaml";
6611
+ import { readFile as readFile21 } from "fs/promises";
6612
+ import { join as join30 } from "path";
6613
+ import { parseDocument as parseDocument7 } from "yaml";
5827
6614
  var DEFAULT_CONFIG4 = {
5828
6615
  enabled: true,
5829
6616
  compareMode: "layout-only",
@@ -5832,8 +6619,8 @@ var DEFAULT_CONFIG4 = {
5832
6619
  };
5833
6620
  async function loadVisualConfig(projectRoot) {
5834
6621
  try {
5835
- const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
5836
- const doc = parseDocument5(raw);
6622
+ const raw = await readFile21(join30(projectRoot, "openspec", "config.yaml"), "utf8");
6623
+ const doc = parseDocument7(raw);
5837
6624
  const fetNode = doc.get("fet", true);
5838
6625
  const node = fetNode?.get?.("visual");
5839
6626
  if (!node || typeof node.get !== "function") {
@@ -5879,10 +6666,10 @@ function isVisualRequiredForVerify(config, hasFigma) {
5879
6666
 
5880
6667
  // src/visual/playwright.ts
5881
6668
  import { createRequire } from "module";
5882
- import { dirname as dirname9, join as join26 } from "path";
6669
+ import { dirname as dirname9, join as join31 } from "path";
5883
6670
  import { pathToFileURL } from "url";
5884
6671
  async function resolvePlaywright(projectRoot) {
5885
- const require2 = createRequire(join26(projectRoot, "package.json"));
6672
+ const require2 = createRequire(join31(projectRoot, "package.json"));
5886
6673
  const candidates = ["playwright", "@playwright/test"];
5887
6674
  for (const name of candidates) {
5888
6675
  try {
@@ -5914,11 +6701,11 @@ async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
5914
6701
  return results;
5915
6702
  }
5916
6703
  async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
5917
- const { mkdir: mkdir15 } = await import("fs/promises");
6704
+ const { mkdir: mkdir18 } = await import("fs/promises");
5918
6705
  const { join: joinPath } = await import("path");
5919
6706
  const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
5920
6707
  const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
5921
- await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
6708
+ await mkdir18(dirname9(screenshotAbsolute), { recursive: true });
5922
6709
  const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
5923
6710
  const pwPage = await browser.newPage();
5924
6711
  try {
@@ -5983,11 +6770,11 @@ async function extractRegions(page, pageDef) {
5983
6770
  }
5984
6771
 
5985
6772
  // src/visual/manifest.ts
5986
- import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
5987
- import { dirname as dirname10, join as join27 } from "path";
5988
- import { parse as parse5, stringify as stringify4 } from "yaml";
6773
+ import { mkdir as mkdir12, readFile as readFile22, stat as stat11 } from "fs/promises";
6774
+ import { dirname as dirname10, join as join32 } from "path";
6775
+ import { parse as parse5, stringify as stringify5 } from "yaml";
5989
6776
  function visualManifestPath(projectRoot, changeId) {
5990
- return join27(projectRoot, visualManifestRelativePath(changeId));
6777
+ return join32(projectRoot, visualManifestRelativePath(changeId));
5991
6778
  }
5992
6779
  async function readVisualManifest(projectRoot, changeId) {
5993
6780
  const path = visualManifestPath(projectRoot, changeId);
@@ -5996,7 +6783,7 @@ async function readVisualManifest(projectRoot, changeId) {
5996
6783
  } catch {
5997
6784
  return null;
5998
6785
  }
5999
- const doc = parse5(await readFile20(path, "utf8"));
6786
+ const doc = parse5(await readFile22(path, "utf8"));
6000
6787
  if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
6001
6788
  return null;
6002
6789
  }
@@ -6004,15 +6791,15 @@ async function readVisualManifest(projectRoot, changeId) {
6004
6791
  }
6005
6792
  async function writeVisualManifest(projectRoot, manifest) {
6006
6793
  const relative6 = visualManifestRelativePath(manifest.changeId);
6007
- const path = join27(projectRoot, relative6);
6008
- await mkdir9(dirname10(path), { recursive: true });
6009
- await atomicWrite(path, stringify4(manifest));
6794
+ const path = join32(projectRoot, relative6);
6795
+ await mkdir12(dirname10(path), { recursive: true });
6796
+ await atomicWrite(path, stringify5(manifest));
6010
6797
  return relative6;
6011
6798
  }
6012
6799
  async function readVisualCapture(projectRoot, changeId) {
6013
- const path = join27(projectRoot, visualCaptureRelativePath(changeId));
6800
+ const path = join32(projectRoot, visualCaptureRelativePath(changeId));
6014
6801
  try {
6015
- const doc = JSON.parse(await readFile20(path, "utf8"));
6802
+ const doc = JSON.parse(await readFile22(path, "utf8"));
6016
6803
  if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
6017
6804
  return doc;
6018
6805
  }
@@ -6023,16 +6810,16 @@ async function readVisualCapture(projectRoot, changeId) {
6023
6810
  }
6024
6811
  async function writeVisualCapture(projectRoot, capture) {
6025
6812
  const relative6 = visualCaptureRelativePath(capture.changeId);
6026
- const path = join27(projectRoot, relative6);
6027
- await mkdir9(dirname10(path), { recursive: true });
6813
+ const path = join32(projectRoot, relative6);
6814
+ await mkdir12(dirname10(path), { recursive: true });
6028
6815
  await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
6029
6816
  `);
6030
6817
  return relative6;
6031
6818
  }
6032
6819
  async function writeVisualResults(projectRoot, results) {
6033
6820
  const relative6 = visualResultsRelativePath(results.changeId);
6034
- const path = join27(projectRoot, relative6);
6035
- await mkdir9(dirname10(path), { recursive: true });
6821
+ const path = join32(projectRoot, relative6);
6822
+ await mkdir12(dirname10(path), { recursive: true });
6036
6823
  await atomicWrite(path, `${JSON.stringify(results, null, 2)}
6037
6824
  `);
6038
6825
  return relative6;
@@ -6426,10 +7213,10 @@ async function ensureManifest(ctx, changeId, baseUrl) {
6426
7213
  baseUrl
6427
7214
  );
6428
7215
  await writeVisualManifest(ctx.projectRoot, manifest);
6429
- const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6430
- await mkdir10(fetDir, { recursive: true });
6431
- await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
6432
- await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
7216
+ const fetDir = join33(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
7217
+ await mkdir13(fetDir, { recursive: true });
7218
+ await atomicWrite(join33(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
7219
+ await atomicWrite(join33(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
6433
7220
  const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6434
7221
  changeState.visual = {
6435
7222
  status: "ready",
@@ -6482,8 +7269,8 @@ async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, ca
6482
7269
 
6483
7270
  // src/commands/verify.ts
6484
7271
  import { createHash as createHash2 } from "crypto";
6485
- import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
6486
- import { join as join29 } from "path";
7272
+ import { mkdir as mkdir14, readFile as readFile23, stat as stat12 } from "fs/promises";
7273
+ import { join as join34 } from "path";
6487
7274
  async function verifyCommand(ctx, options) {
6488
7275
  if (options.auto) {
6489
7276
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -6552,9 +7339,9 @@ async function writeInstructions(ctx, changeId) {
6552
7339
  await assertTestPassed(ctx, changeId);
6553
7340
  await assertVisualPassed(ctx, changeId);
6554
7341
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
6555
- const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6556
- const instructionsPath = join29(dir, "verify-instructions.md");
6557
- await mkdir11(dir, { recursive: true });
7342
+ const dir = join34(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
7343
+ const instructionsPath = join34(dir, "verify-instructions.md");
7344
+ await mkdir14(dir, { recursive: true });
6558
7345
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
6559
7346
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6560
7347
  state.currentPhase = "verify";
@@ -6572,7 +7359,7 @@ async function markDone(ctx, changeId) {
6572
7359
  await assertTestPassed(ctx, changeId);
6573
7360
  await assertVisualPassed(ctx, changeId);
6574
7361
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
6575
- const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
7362
+ const instructionsPath = join34(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
6576
7363
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
6577
7364
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
6578
7365
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -6597,7 +7384,7 @@ async function markDone(ctx, changeId) {
6597
7384
  async function readInstructions(ctx, path, changeId) {
6598
7385
  try {
6599
7386
  await stat12(path);
6600
- const content = await readFile21(path, "utf8");
7387
+ const content = await readFile23(path, "utf8");
6601
7388
  const fileChangeId = readFrontMatterValue(content, "changeId");
6602
7389
  if (fileChangeId !== changeId) {
6603
7390
  throw new FetError({
@@ -6720,9 +7507,9 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
6720
7507
  import { resolve } from "path";
6721
7508
 
6722
7509
  // src/adapters/codex/index.ts
6723
- import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
7510
+ import { mkdir as mkdir15, readFile as readFile24, stat as stat13 } from "fs/promises";
6724
7511
  import { homedir } from "os";
6725
- import { dirname as dirname11, join as join30 } from "path";
7512
+ import { dirname as dirname11, join as join35 } from "path";
6726
7513
 
6727
7514
  // src/adapters/commands.ts
6728
7515
  var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
@@ -7383,7 +8170,7 @@ Steps:
7383
8170
  3. Follow the native apply output. If JSON output is unavailable, read the files referenced by the terminal output and the artifacts under openspec/changes/<change-id>/.
7384
8171
  4. If \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\` exists (or apply nextSteps mention Figma):
7385
8172
  - Read it and \`figma-stop.md\` in the same folder before any UI code.
7386
- - Use Figma MCP/API to read every linked frame; briefly confirm design facts in your reply.
8173
+ - Run \`fet figma sync\` when possible; read \`figma-design-manifest.yaml\`, \`figma-nodes.json\`, and \`.fet/figma-assets/\` (file paths for large images). Otherwise use Figma MCP/API for every linked frame; briefly confirm design facts in your reply.
7387
8174
  - If Figma access fails or design details are unclear, stop and ask the user\u2014do not guess styles or implement UI.
7388
8175
  5. If \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\` exists (or apply nextSteps mention UI display contract):
7389
8176
  - Read it and \`ui-field-apply-instructions.md\` in the same folder before UI that binds API data.
@@ -7929,7 +8716,7 @@ var CodexAdapter = class {
7929
8716
  adapterVersion = 1;
7930
8717
  async detect(projectRoot) {
7931
8718
  return {
7932
- detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
8719
+ detected: await exists6(join35(projectRoot, ".codex")) || await exists6(join35(projectRoot, "AGENTS.md")),
7933
8720
  reason: "Codex adapter is available for projects that use AGENTS.md"
7934
8721
  };
7935
8722
  }
@@ -7968,7 +8755,7 @@ var CodexAdapter = class {
7968
8755
  if (existing && !existing.includes("FET:MANAGED") && force) {
7969
8756
  await createBackup(target);
7970
8757
  }
7971
- await mkdir12(dirname11(target), { recursive: true });
8758
+ await mkdir15(dirname11(target), { recursive: true });
7972
8759
  await atomicWrite(target, file.content);
7973
8760
  written.push(displayPath);
7974
8761
  }
@@ -7995,9 +8782,9 @@ var CodexAdapter = class {
7995
8782
  };
7996
8783
  function resolveTarget(projectRoot, file) {
7997
8784
  if (file.root === "codex-home") {
7998
- return join30(resolveCodexHome(), file.path);
8785
+ return join35(resolveCodexHome(), file.path);
7999
8786
  }
8000
- return join30(projectRoot, file.path);
8787
+ return join35(projectRoot, file.path);
8001
8788
  }
8002
8789
  function displayPathFor(file) {
8003
8790
  if (file.root === "codex-home") {
@@ -8006,11 +8793,11 @@ function displayPathFor(file) {
8006
8793
  return file.path;
8007
8794
  }
8008
8795
  function resolveCodexHome() {
8009
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
8796
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join35(homedir(), ".codex");
8010
8797
  }
8011
8798
  async function readExisting(path) {
8012
8799
  try {
8013
- return await readFile22(path, "utf8");
8800
+ return await readFile24(path, "utf8");
8014
8801
  } catch {
8015
8802
  return null;
8016
8803
  }
@@ -8025,8 +8812,8 @@ async function exists6(path) {
8025
8812
  }
8026
8813
 
8027
8814
  // src/adapters/cursor/index.ts
8028
- import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
8029
- import { dirname as dirname12, join as join31 } from "path";
8815
+ import { mkdir as mkdir16, readFile as readFile25, stat as stat14 } from "fs/promises";
8816
+ import { dirname as dirname12, join as join36 } from "path";
8030
8817
 
8031
8818
  // src/adapters/cursor/templates.ts
8032
8819
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -8272,12 +9059,14 @@ function renderApplySkill(usage, language) {
8272
9059
  const figmaBlock = language === "en" ? `If \`fet apply\` output or the change has \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\`:
8273
9060
 
8274
9061
  1. Read that file and \`figma-stop.md\` in the same folder before any UI code.
8275
- 2. Use Figma MCP/API to read every linked frame; do not invent colors, spacing, or layout.
9062
+ 2. Run \`fet figma sync\` when possible; read \`figma-design-manifest.yaml\`, \`figma-nodes.json\`, and \`.fet/figma-assets/\` (use file paths for large images, not base64).
9063
+ 3. Otherwise use Figma MCP/API for every linked frame; do not invent colors, spacing, or layout.
8276
9064
  3. If Figma fails or design is unclear, stop and ask the user\u2014do not guess styles or mark UI tasks done.
8277
9065
  4. After design is confirmed, implement tasks from \`tasks.md\`.` : `\u82E5 \`fet apply\` \u8F93\u51FA\u6216 change \u4E2D\u5B58\u5728 \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\`\uFF1A
8278
9066
 
8279
9067
  1. \u5728\u6539\u4EFB\u4F55 UI \u4EE3\u7801\u524D\uFF0C\u5148\u9605\u8BFB\u8BE5\u6587\u4EF6\u4E0E\u540C\u76EE\u5F55 \`figma-stop.md\`\u3002
8280
- 2. \u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u94FE\u63A5\u7684\u753B\u677F\uFF1B\u7981\u6B62\u81EA\u521B\u989C\u8272\u3001\u95F4\u8DDD\u3001\u5E03\u5C40\u3002
9068
+ 2. \u4F18\u5148\u6267\u884C \`fet figma sync\`\uFF1B\u9605\u8BFB \`figma-design-manifest.yaml\`\u3001\`figma-nodes.json\` \u4E0E \`.fet/figma-assets/\`\uFF08\u5927\u56FE\u7528\u6587\u4EF6\u8DEF\u5F84\uFF0C\u7981\u6B62 base64\uFF09\u3002
9069
+ 3. \u5426\u5219\u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u753B\u677F\uFF1B\u7981\u6B62\u81EA\u521B\u989C\u8272\u3001\u95F4\u8DDD\u3001\u5E03\u5C40\u3002
8281
9070
  3. Figma \u5931\u8D25\u6216\u8BBE\u8BA1\u4E0D\u6E05\u65F6\u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u4E0D\u8981\u731C\u6837\u5F0F\uFF0C\u4E0D\u8981\u628A UI \u4EFB\u52A1\u6807\u4E3A\u5B8C\u6210\u3002
8282
9071
  4. \u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD\u4EFB\u52A1\u3002`;
8283
9072
  return `<!-- FET:MANAGED
@@ -8381,7 +9170,7 @@ var CursorAdapter = class {
8381
9170
  adapterVersion = 1;
8382
9171
  async detect(projectRoot) {
8383
9172
  return {
8384
- detected: await exists7(join31(projectRoot, ".cursor")),
9173
+ detected: await exists7(join36(projectRoot, ".cursor")),
8385
9174
  reason: "Cursor adapter is available for any project"
8386
9175
  };
8387
9176
  }
@@ -8398,10 +9187,10 @@ var CursorAdapter = class {
8398
9187
  const written = [];
8399
9188
  const skipped = [];
8400
9189
  for (const file of plan.files) {
8401
- const target = join31(projectRoot, file.path);
9190
+ const target = join36(projectRoot, file.path);
8402
9191
  const existing = await readExisting2(target);
8403
9192
  if (file.path === ".cursor/hooks.json") {
8404
- await mkdir13(dirname12(target), { recursive: true });
9193
+ await mkdir16(dirname12(target), { recursive: true });
8405
9194
  await atomicWrite(target, mergeCursorHooksJson(existing, file.content));
8406
9195
  written.push(file.path);
8407
9196
  continue;
@@ -8417,7 +9206,7 @@ var CursorAdapter = class {
8417
9206
  if (existing && !existing.includes("FET:MANAGED") && force) {
8418
9207
  await createBackup(target);
8419
9208
  }
8420
- await mkdir13(dirname12(target), { recursive: true });
9209
+ await mkdir16(dirname12(target), { recursive: true });
8421
9210
  await atomicWrite(target, file.content);
8422
9211
  written.push(file.path);
8423
9212
  }
@@ -8427,7 +9216,7 @@ var CursorAdapter = class {
8427
9216
  const plan = await this.planInstall(projectRoot);
8428
9217
  const checks = [];
8429
9218
  for (const file of plan.files) {
8430
- const target = join31(projectRoot, file.path);
9219
+ const target = join36(projectRoot, file.path);
8431
9220
  const content = await readExisting2(target);
8432
9221
  const hooksManaged = file.path === ".cursor/hooks.json" && Boolean(content?.includes('"writeBoundary"'));
8433
9222
  const hookScript = file.path.startsWith(".cursor/hooks/fet-guard-") && file.path.endsWith(".mjs");
@@ -8445,7 +9234,7 @@ var CursorAdapter = class {
8445
9234
  };
8446
9235
  async function readExisting2(path) {
8447
9236
  try {
8448
- return await readFile23(path, "utf8");
9237
+ return await readFile25(path, "utf8");
8449
9238
  } catch {
8450
9239
  return null;
8451
9240
  }
@@ -8465,12 +9254,12 @@ import { promisify as promisify4 } from "util";
8465
9254
 
8466
9255
  // src/openspec/inspector.ts
8467
9256
  import { readdir as readdir6, stat as stat15 } from "fs/promises";
8468
- import { join as join32 } from "path";
9257
+ import { join as join37 } from "path";
8469
9258
  async function inspectOpenSpecProject(projectRoot) {
8470
- const openspecPath = join32(projectRoot, "openspec");
8471
- const changesPath = join32(openspecPath, "changes");
8472
- const legacyArchivePath = join32(openspecPath, "archive");
8473
- const changesArchivePath = join32(changesPath, "archive");
9259
+ const openspecPath = join37(projectRoot, "openspec");
9260
+ const changesPath = join37(openspecPath, "changes");
9261
+ const legacyArchivePath = join37(openspecPath, "archive");
9262
+ const changesArchivePath = join37(changesPath, "archive");
8474
9263
  return {
8475
9264
  exists: await exists8(openspecPath),
8476
9265
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
@@ -8478,13 +9267,13 @@ async function inspectOpenSpecProject(projectRoot) {
8478
9267
  };
8479
9268
  }
8480
9269
  async function inspectOpenSpecChange(projectRoot, changeId) {
8481
- const changePath = join32(projectRoot, "openspec", "changes", changeId);
8482
- const tasksPath = join32(changePath, "tasks.md");
8483
- const specsPath = join32(changePath, "specs");
9270
+ const changePath = join37(projectRoot, "openspec", "changes", changeId);
9271
+ const tasksPath = join37(changePath, "tasks.md");
9272
+ const specsPath = join37(changePath, "specs");
8484
9273
  return {
8485
9274
  changeId,
8486
9275
  exists: await exists8(changePath),
8487
- hasProposal: await exists8(join32(changePath, "proposal.md")),
9276
+ hasProposal: await exists8(join37(changePath, "proposal.md")),
8488
9277
  hasTasks: await exists8(tasksPath),
8489
9278
  hasSpecs: await exists8(specsPath),
8490
9279
  tasksPath,
@@ -8686,12 +9475,12 @@ function escapeRegExp(value) {
8686
9475
 
8687
9476
  // src/scanner/routes.ts
8688
9477
  import { readdir as readdir7, stat as stat16 } from "fs/promises";
8689
- import { join as join33, relative as relative5, sep } from "path";
9478
+ import { join as join38, relative as relative5, sep } from "path";
8690
9479
  async function scanRoutes(projectRoot) {
8691
9480
  const candidates = ["src/routes", "src/pages", "app", "pages"];
8692
9481
  const routes = [];
8693
9482
  for (const candidate of candidates) {
8694
- const root = join33(projectRoot, candidate);
9483
+ const root = join38(projectRoot, candidate);
8695
9484
  if (!await exists9(root)) {
8696
9485
  continue;
8697
9486
  }
@@ -8719,7 +9508,7 @@ async function listFiles(root) {
8719
9508
  const entries = await readdir7(root, { withFileTypes: true });
8720
9509
  const files = [];
8721
9510
  for (const entry of entries) {
8722
- const path = join33(root, entry.name);
9511
+ const path = join38(root, entry.name);
8723
9512
  if (entry.isDirectory()) {
8724
9513
  files.push(...await listFiles(path));
8725
9514
  } else {
@@ -8887,9 +9676,9 @@ async function createCommandContext(command, options) {
8887
9676
  import { createInterface as createInterface2 } from "readline/promises";
8888
9677
 
8889
9678
  // src/update/check.ts
8890
- import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
9679
+ import { mkdir as mkdir17, readFile as readFile26, writeFile as writeFile3 } from "fs/promises";
8891
9680
  import { homedir as homedir2 } from "os";
8892
- import { dirname as dirname13, join as join34 } from "path";
9681
+ import { dirname as dirname13, join as join39 } from "path";
8893
9682
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
8894
9683
  function getFetUpdateCheckMode(env = process.env) {
8895
9684
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -8962,11 +9751,11 @@ function formatFetUpdateWarning(availability, language) {
8962
9751
  }
8963
9752
  function cachePath() {
8964
9753
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
8965
- return join34(home, ".fet", "update-check-cache.json");
9754
+ return join39(home, ".fet", "update-check-cache.json");
8966
9755
  }
8967
9756
  async function readUpdateCheckCache() {
8968
9757
  try {
8969
- const raw = await readFile24(cachePath(), "utf8");
9758
+ const raw = await readFile26(cachePath(), "utf8");
8970
9759
  const parsed = JSON.parse(raw);
8971
9760
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
8972
9761
  return null;
@@ -8982,8 +9771,8 @@ async function readUpdateCheckCache() {
8982
9771
  }
8983
9772
  async function writeUpdateCheckCache(cache) {
8984
9773
  const path = cachePath();
8985
- await mkdir14(dirname13(path), { recursive: true });
8986
- await writeFile(path, `${JSON.stringify(cache, null, 2)}
9774
+ await mkdir17(dirname13(path), { recursive: true });
9775
+ await writeFile3(path, `${JSON.stringify(cache, null, 2)}
8987
9776
  `, "utf8");
8988
9777
  }
8989
9778
 
@@ -9070,6 +9859,19 @@ addGlobalOptions(program.command("doctor").description("\u8BCA\u65AD\u72B6\u6001
9070
9859
  wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
9071
9860
  );
9072
9861
  addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4EA7\u7269\u751F\u6210 change \u7EA7 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15")).action(wrap("tdd", tddCommand));
9862
+ var figma = addGlobalOptions(program.command("figma").description("Figma \u8BBE\u8BA1\u7A3F\u540C\u6B65\uFF08\u62C9\u53D6\u8282\u70B9\u6811\u5E76\u4E0B\u8F7D\u7D20\u6750\uFF09"));
9863
+ addGlobalOptions(
9864
+ figma.command("sync").description("\u4ECE Figma API \u540C\u6B65\u5168\u90E8\u8282\u70B9\u4E0E\u56FE\u7247\u7D20\u6750\u5230 change/.fet/").option("--token <token>", "Figma Personal Access Token\uFF08\u4E5F\u53EF\u7528 FIGMA_ACCESS_TOKEN\uFF09").option("--force", "\u5F3A\u5236\u91CD\u65B0\u4E0B\u8F7D\uFF0C\u5FFD\u7565\u5DF2\u6709\u540C\u6B65\u7ED3\u679C").option("--plan", "\u4EC5\u8F93\u51FA\u540C\u6B65\u8BA1\u5212\uFF0C\u4E0D\u8C03\u7528 Figma API")
9865
+ ).action(
9866
+ wrap(
9867
+ "figma",
9868
+ (ctx, options) => figmaSyncCommand(ctx, {
9869
+ token: options.token,
9870
+ force: Boolean(options.force),
9871
+ plan: Boolean(options.plan)
9872
+ })
9873
+ )
9874
+ );
9073
9875
  addGlobalOptions(program.command("test").description("\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B\u5E76\u8BB0\u5F55\u7EFF\u706F\u72B6\u6001").option("--plan", "\u4EC5\u8F93\u51FA\u5C06\u6267\u884C\u7684\u6D4B\u8BD5\u547D\u4EE4\uFF0C\u4E0D\u8FD0\u884C")).action(
9074
9876
  wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
9075
9877
  );
@@ -9202,4 +10004,3 @@ function extractGlobalOptions(args) {
9202
10004
  }
9203
10005
  return options;
9204
10006
  }
9205
- //# sourceMappingURL=index.js.map