# Figma Component Library — Implementation Plan (M1)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a new `library` subsystem to CodeDesign2 that lets users bind their Figma DS file, builds a local component index by REST-walking the DS, and lets the AI place 1:1 visual reproductions of those components onto a target Figma canvas via Plugin code generation.

**Architecture:** New `src/library/` module (types/paths/manifest/fetcher/rebuild) + 3 new AI tools (`library_add`, `list_components`, `place_component`). Storage at `~/.codedesign/libraries/<slug>/` mirrors the existing `knowledge/` pack layout. Plugin code is generated as JS strings and dispatched through the existing `executeFigmaCode()` MCP bridge. No new external dependencies.

**Tech Stack:** TypeScript (strict), Node 20+, `node --test` with tsx loader, `zod` for tool argument parsing, Figma REST API, Figma Plugin API.

**Prerequisites:**
- This repo (`/Users/wz/Desktop/CodeDesign2/codedesign-ts`) is **not** currently a git repository. Before executing, run from that directory:
  ```bash
  git init && git add -A && git commit -m "baseline: alpha.61"
  ```
  Without this the commit steps fail; alternatively the executor can skip the `git add` / `git commit` step in each task and aggregate commits at the end.
- `pnpm install` has been run; `pnpm build` and `pnpm typecheck` pass cleanly on the current tree.
- `FIGMA_ACCESS_TOKEN` (PAT) available for Tier 2 smoke; not required for unit tests.

**Reference spec:** `docs/superpowers/specs/2026-05-22-figma-component-library-design.md`

---

## File Map

**Create:**
- `src/library/types.ts` — `ManifestEntry`, `SourceJson`, `LibraryPack`, plus shared shape types
- `src/library/paths.ts` — `~/.codedesign/libraries/<slug>/` path helpers + slug sanitization
- `src/library/manifest.ts` — load/save manifest, score-based search across one or many libraries
- `src/library/fetcher.ts` — `walkComponents` (pure) + `runLibraryAdd` (REST orchestration + disk writes)
- `src/library/rebuild.ts` — `buildPluginCode` (recursive node → Plugin API JS string)
- `src/tools/libraryAdd.ts` — thin AI tool wrapping `runLibraryAdd`
- `src/tools/listComponents.ts` — thin AI tool wrapping `searchManifest`
- `src/tools/placeComponent.ts` — AI tool: builds Plugin code, dispatches via `executeFigmaCode`
- `tests/libraryPaths.test.ts`
- `tests/libraryManifest.test.ts`
- `tests/libraryFetcher.test.ts`
- `tests/libraryRebuild.test.ts`
- `tests/fixtures/library/btn-cs.json` — trimmed COMPONENT_SET fixture
- `tests/fixtures/library/page-button.json` — trimmed page fixture
- `scripts/library-smoke.sh`

**Modify:**
- `src/repl.tsx:354-381` — register the 3 new tools after `makeVisualDiffTool`
- `docs/figma-setup.md` — append a "Component Library" section pointing at the new tools

---

## Task 1: Type Definitions and Path Helpers

**Files:**
- Create: `src/library/types.ts`
- Create: `src/library/paths.ts`
- Test: `tests/libraryPaths.test.ts`

These have no behaviour beyond constants and pure string transforms — small but lock in the schema every other task depends on.

- [ ] **Step 1: Write `src/library/types.ts`**

```typescript
/**
 * Shared types for the library subsystem. Kept separate from fetcher/manifest/
 * rebuild so all three can import without circular deps.
 */

/** Variant axis name → all values that appear across the COMPONENT_SET children. */
export type ManifestVariants = Record<string, string[]>;

export type ManifestEntry = {
  /** Node id inside the source DS file (e.g. "13:47945"). Unique within one library. */
  key: string;
  /** Global Figma component key (the hash). Captured for future path-1 migration. */
  component_key?: string;
  /** Display name as it appears in Figma. */
  name: string;
  /** What this entry is. `set` = COMPONENT_SET with variants, `single` = standalone COMPONENT, `nested` = referenced via INSTANCE inside another component. */
  kind: "set" | "single" | "nested";
  /** Top-level page name from the DS file — used as category. */
  page: string;
  /** Only present for `kind === "set"`. */
  variants?: ManifestVariants;
  /** Cross-library remote — children not retrievable, place_component falls back to a placeholder frame. */
  remote: boolean;
  /** Optional description from the Figma file. */
  description?: string;
};

export type SourceJson = {
  slug: string;
  file_key: string;
  file_name: string;
  /** ISO timestamp from /v1/files/:key `lastModified`. */
  lastModified: string;
  /** ISO timestamp of when `library_add` ran. */
  scanned_at: string;
  total: number;
  /** Page name → count of entries placed under that page. */
  by_page: Record<string, number>;
};

export type LibraryPack = {
  slug: string;
  dir: string;
  source: SourceJson;
  manifest: ManifestEntry[];
};
```

- [ ] **Step 2: Write `src/library/paths.ts`**

```typescript
import path from "node:path";
import os from "node:os";

/** Root directory for all libraries. Mirrors KNOWLEDGE_DIR semantics. */
export const LIBRARIES_DIR = path.join(os.homedir(), ".codedesign", "libraries");

export function libraryDir(slug: string): string {
  return path.join(LIBRARIES_DIR, slug);
}

export function manifestPath(slug: string): string {
  return path.join(libraryDir(slug), "manifest.json");
}

export function sourcePath(slug: string): string {
  return path.join(libraryDir(slug), "source.json");
}

/** Figma node ids contain ":", which would split path segments on some shells. Replace with "__". */
function safeKey(key: string): string {
  return key.replace(/:/g, "__");
}

export function nodeJsonPath(slug: string, componentKey: string): string {
  return path.join(libraryDir(slug), "nodes", `${safeKey(componentKey)}.json`);
}

export function vectorsDir(slug: string, componentKey: string): string {
  return path.join(libraryDir(slug), "nodes", `${safeKey(componentKey)}.vectors`);
}

export function vectorSvgPath(slug: string, componentKey: string, vectorNodeId: string): string {
  return path.join(vectorsDir(slug, componentKey), `${safeKey(vectorNodeId)}.svg`);
}

/**
 * Derive a default slug from a Figma file name. Lowercases, normalizes
 * whitespace and separators, keeps CJK characters and ASCII alphanum.
 */
export function defaultSlug(fileName: string): string {
  return fileName
    .toLowerCase()
    .replace(/[\s/\\()（）.,，·]+/g, "-")
    .replace(/[^a-z0-9一-龥-]/g, "")
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "");
}
```

- [ ] **Step 3: Write `tests/libraryPaths.test.ts`**

```typescript
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import os from "node:os";
import {
  LIBRARIES_DIR,
  libraryDir,
  manifestPath,
  nodeJsonPath,
  vectorSvgPath,
  defaultSlug,
} from "../src/library/paths.js";

test("LIBRARIES_DIR is anchored at ~/.codedesign/libraries", () => {
  assert.equal(LIBRARIES_DIR, path.join(os.homedir(), ".codedesign", "libraries"));
});

test("libraryDir composes correctly", () => {
  assert.equal(libraryDir("ruliu"), path.join(LIBRARIES_DIR, "ruliu"));
});

test("manifestPath sits under the library dir", () => {
  assert.equal(manifestPath("ruliu"), path.join(LIBRARIES_DIR, "ruliu", "manifest.json"));
});

test("nodeJsonPath replaces colons with double underscore", () => {
  const p = nodeJsonPath("ruliu", "13:47945");
  assert.equal(path.basename(p), "13__47945.json");
});

test("vectorSvgPath nests under a .vectors directory per component", () => {
  const p = vectorSvgPath("ruliu", "13:47945", "1:57");
  assert.equal(
    p,
    path.join(LIBRARIES_DIR, "ruliu", "nodes", "13__47945.vectors", "1__57.svg"),
  );
});

test("defaultSlug handles a real DS file name", () => {
  assert.equal(defaultSlug("如流设计规范-移动端 （自整理）"), "如流设计规范-移动端-自整理");
});

test("defaultSlug normalizes ascii separators", () => {
  assert.equal(defaultSlug("Acme Design System v2.0"), "acme-design-system-v2-0");
});

test("defaultSlug drops trailing dashes", () => {
  assert.equal(defaultSlug("-leading--double--trailing-"), "leading-double-trailing");
});
```

- [ ] **Step 4: Run tests, expect FAIL (modules not yet created on disk... wait they are)**

Run: `pnpm test --test-name-pattern='libraryPaths' 2>&1 | tail -20`

Expected: all 7 tests PASS, since step 1+2 created the modules.

If a test fails, fix the source file (not the test) and re-run.

- [ ] **Step 5: Run typecheck**

Run: `pnpm typecheck`

Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add src/library/types.ts src/library/paths.ts tests/libraryPaths.test.ts
git commit -m "feat(library): add ManifestEntry/SourceJson types and path helpers"
```

---

## Task 2: Manifest Read/Search

**Files:**
- Create: `src/library/manifest.ts`
- Test: `tests/libraryManifest.test.ts`

`manifest.ts` is pure I/O + scoring. No network. Easy to unit-test fully.

- [ ] **Step 1: Write `tests/libraryManifest.test.ts`**

```typescript
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeLibrary, loadLibrary, searchManifest, listLibraries } from "../src/library/manifest.js";
import type { ManifestEntry, SourceJson } from "../src/library/types.js";

function tempRoot(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), "lib-mft-"));
}

function sampleEntries(): ManifestEntry[] {
  return [
    { key: "13:47945", name: "移动端填充按钮-46px", kind: "set", page: "按钮", remote: false,
      variants: { "Property 1": ["默认", "选中", "禁用"] } },
    { key: "13:48076", name: "移动端主要按钮-46px", kind: "set", page: "按钮", remote: false,
      variants: { "Property 1": ["默认", "down"] } },
    { key: "20:1234", name: "顶部导航栏", kind: "single", page: "导航菜单", remote: false },
    { key: "30:5678", name: "右箭头", kind: "nested", page: "图标", remote: true },
  ];
}

function sampleSource(): SourceJson {
  return {
    slug: "ruliu-mobile",
    file_key: "D3T0pVvPLHfxhZ5Ip4oCpF",
    file_name: "如流设计规范-移动端",
    lastModified: "2026-05-21T09:39:12Z",
    scanned_at: "2026-05-22T10:00:00Z",
    total: 4,
    by_page: { "按钮": 2, "导航菜单": 1, "图标": 1 },
  };
}

test("writeLibrary then loadLibrary round-trips", () => {
  const root = tempRoot();
  writeLibrary("ruliu-mobile", sampleSource(), sampleEntries(), { root });
  const pack = loadLibrary("ruliu-mobile", { root });
  assert.ok(pack);
  assert.equal(pack!.manifest.length, 4);
  assert.equal(pack!.source.file_key, "D3T0pVvPLHfxhZ5Ip4oCpF");
});

test("loadLibrary returns null for missing slug", () => {
  const root = tempRoot();
  assert.equal(loadLibrary("nope", { root }), null);
});

test("searchManifest: exact name match scores highest", () => {
  const root = tempRoot();
  writeLibrary("ruliu", sampleSource(), sampleEntries(), { root });
  const r = searchManifest({ query: "移动端填充按钮-46px", root, limit: 10 });
  assert.equal(r.matches[0]!.name, "移动端填充按钮-46px");
});

test("searchManifest: page filter restricts results", () => {
  const root = tempRoot();
  writeLibrary("ruliu", sampleSource(), sampleEntries(), { root });
  const r = searchManifest({ page: "按钮", root });
  assert.equal(r.matches.length, 2);
  assert.ok(r.matches.every((m) => m.page === "按钮"));
});

test("searchManifest: substring query ranks by score", () => {
  const root = tempRoot();
  writeLibrary("ruliu", sampleSource(), sampleEntries(), { root });
  const r = searchManifest({ query: "按钮", root });
  // "按钮" page matches all three button entries, and 0 non-button entries
  assert.ok(r.matches.length >= 2);
  assert.ok(r.matches.every((m) => m.page === "按钮" || m.name.includes("按钮")));
});

test("searchManifest: limit caps results and reports truncated", () => {
  const root = tempRoot();
  writeLibrary("ruliu", sampleSource(), sampleEntries(), { root });
  const r = searchManifest({ root, limit: 2 });
  assert.equal(r.matches.length, 2);
  assert.equal(r.truncated, true);
});

test("searchManifest: explicit library slug overrides default scan", () => {
  const root = tempRoot();
  writeLibrary("a", sampleSource(), sampleEntries(), { root });
  writeLibrary("b", { ...sampleSource(), file_key: "OTHER" }, [], { root });
  const r = searchManifest({ library: "b", root });
  assert.equal(r.matches.length, 0);
  assert.equal(r.library, "b");
});

test("listLibraries enumerates installed slugs", () => {
  const root = tempRoot();
  writeLibrary("a", sampleSource(), [], { root });
  writeLibrary("b", sampleSource(), [], { root });
  const slugs = listLibraries({ root });
  assert.deepEqual(slugs.sort(), ["a", "b"]);
});
```

- [ ] **Step 2: Run tests, expect FAIL ("Cannot find module ../src/library/manifest.js")**

Run: `pnpm test --test-name-pattern='Manifest|searchManifest|writeLibrary|loadLibrary|listLibraries' 2>&1 | tail -30`

Expected: all FAIL with module-not-found.

- [ ] **Step 3: Write `src/library/manifest.ts`**

```typescript
import fs from "node:fs";
import path from "node:path";
import type { ManifestEntry, SourceJson, LibraryPack } from "./types.js";
import {
  LIBRARIES_DIR,
  libraryDir,
  manifestPath,
  sourcePath,
} from "./paths.js";

export type Roots = { root?: string };

function rootDir(opts: Roots): string {
  return opts.root ?? LIBRARIES_DIR;
}

function pathsFor(slug: string, opts: Roots) {
  const dir = path.join(rootDir(opts), slug);
  return {
    dir,
    manifest: path.join(dir, "manifest.json"),
    source: path.join(dir, "source.json"),
  };
}

/** Atomic-ish write: ensures parent dir, writes file. */
function writeJson(file: string, value: unknown): void {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify(value, null, 2), "utf8");
}

export function writeLibrary(
  slug: string,
  source: SourceJson,
  manifest: ManifestEntry[],
  opts: Roots = {},
): void {
  const p = pathsFor(slug, opts);
  writeJson(p.manifest, manifest);
  writeJson(p.source, source);
}

export function loadLibrary(slug: string, opts: Roots = {}): LibraryPack | null {
  const p = pathsFor(slug, opts);
  if (!fs.existsSync(p.manifest) || !fs.existsSync(p.source)) return null;
  const manifest = JSON.parse(fs.readFileSync(p.manifest, "utf8")) as ManifestEntry[];
  const source = JSON.parse(fs.readFileSync(p.source, "utf8")) as SourceJson;
  return { slug, dir: p.dir, source, manifest };
}

export function listLibraries(opts: Roots = {}): string[] {
  const dir = rootDir(opts);
  if (!fs.existsSync(dir)) return [];
  return fs.readdirSync(dir, { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .filter((e) => fs.existsSync(path.join(dir, e.name, "manifest.json")))
    .map((e) => e.name);
}

export type SearchOpts = Roots & {
  query?: string;
  page?: string;
  library?: string;
  limit?: number;
};

export type SearchHit = {
  library: string;
  key: string;
  name: string;
  page: string;
  kind: ManifestEntry["kind"];
  variants?: ManifestEntry["variants"];
};

export type SearchResult = {
  library: string | "all";
  total: number;
  matches: SearchHit[];
  truncated: boolean;
};

/**
 * Score an entry against a free-text query. Higher = more relevant. Returns
 * 0 when no relevance signal fires — those entries are dropped.
 */
function scoreEntry(e: ManifestEntry, query: string): number {
  const q = query.toLowerCase();
  const name = e.name.toLowerCase();
  const page = e.page.toLowerCase();
  if (name === q) return 100;
  if (page === q) return 80;
  if (name.startsWith(q)) return 60;
  if (name.includes(q)) return 30;
  if (page.includes(q)) return 25;
  if (e.variants) {
    for (const values of Object.values(e.variants)) {
      if (values.some((v) => v.toLowerCase() === q)) return 20;
    }
  }
  return 0;
}

export function searchManifest(opts: SearchOpts): SearchResult {
  const limit = opts.limit ?? 20;
  const slugs = opts.library ? [opts.library] : listLibraries(opts);
  const packs = slugs
    .map((s) => loadLibrary(s, opts))
    .filter((p): p is LibraryPack => p !== null);

  let entries: { hit: SearchHit; score: number }[] = [];
  for (const pack of packs) {
    for (const e of pack.manifest) {
      if (opts.page && e.page !== opts.page) continue;
      let score = opts.query ? scoreEntry(e, opts.query) : 1;
      if (score <= 0) continue;
      entries.push({
        score,
        hit: {
          library: pack.slug,
          key: e.key,
          name: e.name,
          page: e.page,
          kind: e.kind,
          variants: e.variants,
        },
      });
    }
  }
  entries.sort((a, b) => b.score - a.score);
  const total = entries.length;
  const matches = entries.slice(0, limit).map((e) => e.hit);
  return {
    library: opts.library ?? "all",
    total,
    matches,
    truncated: total > limit,
  };
}
```

- [ ] **Step 4: Run tests, expect PASS**

Run: `pnpm test --test-name-pattern='Manifest|searchManifest|writeLibrary|loadLibrary|listLibraries' 2>&1 | tail -30`

Expected: all 8 tests PASS. If any fail, fix the source (not the test) and re-run.

- [ ] **Step 5: typecheck**

Run: `pnpm typecheck`
Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add src/library/manifest.ts tests/libraryManifest.test.ts
git commit -m "feat(library): manifest read/write and score-based search"
```

---

## Task 3: Fixture Preparation + `walkComponents` Pure Function

**Files:**
- Create: `tests/fixtures/library/btn-cs.json`
- Create: `tests/fixtures/library/page-button.json`
- Create: `src/library/fetcher.ts` (initial export only — `walkComponents` pure function)
- Test: `tests/libraryFetcher.test.ts`

Splitting fetcher into two tasks: this task is the pure node-tree walker (fully unit-testable). Task 4 adds the network/disk orchestration.

- [ ] **Step 1: Copy fixtures from the validation runs earlier**

The validation earlier downloaded real DS data to `/tmp/`. Copy two trimmed slices into the repo as fixtures.

Run:
```bash
mkdir -p tests/fixtures/library
# btn-cs.json: the ComponentSet "移动端填充按钮-46px"
cp /tmp/btn-cs.json tests/fixtures/library/btn-cs.json
# page-button.json: the "按钮" page (CANVAS) with its instances and the
# file-level components map
cp /tmp/page-button.json tests/fixtures/library/page-button.json
ls -la tests/fixtures/library/
```

Expected: both files present, ~30KB and ~1MB respectively. If `/tmp` was cleared, regenerate with:

```bash
TOKEN="$FIGMA_ACCESS_TOKEN"
curl -s -H "X-Figma-Token: $TOKEN" \
  "https://api.figma.com/v1/files/D3T0pVvPLHfxhZ5Ip4oCpF/nodes?ids=13:47945" \
  -o tests/fixtures/library/btn-cs.json
curl -s -H "X-Figma-Token: $TOKEN" \
  "https://api.figma.com/v1/files/D3T0pVvPLHfxhZ5Ip4oCpF/nodes?ids=1:22" \
  -o tests/fixtures/library/page-button.json
```

- [ ] **Step 2: Write the test file**

`tests/libraryFetcher.test.ts`:

```typescript
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { walkComponents } from "../src/library/fetcher.js";
import type { FigmaNode } from "../src/figma/rest.js";

function loadFixture(name: string): any {
  const p = path.join(process.cwd(), "tests", "fixtures", "library", name);
  return JSON.parse(fs.readFileSync(p, "utf8"));
}

test("walkComponents finds the COMPONENT_SET in btn-cs fixture", () => {
  const f = loadFixture("btn-cs.json");
  const cs = f.nodes["13:47945"].document as FigmaNode;
  const componentsMap = f.nodes["13:47945"].components ?? {};
  const result = walkComponents({
    root: cs,
    pageName: "按钮",
    fileComponentsMap: componentsMap,
  });
  // ComponentSet itself counted as a `set` entry
  const sets = result.entries.filter((e) => e.kind === "set");
  assert.equal(sets.length, 1);
  assert.equal(sets[0]!.name, "移动端填充按钮-46px");
  assert.deepEqual(sets[0]!.variants, {
    "Property 1": ["默认", "选中", "down", "禁用", "loading"],
  });
});

test("walkComponents collects INSTANCE componentIds in nestedIds", () => {
  const f = loadFixture("page-button.json");
  const page = f.nodes["1:22"].document as FigmaNode;
  const componentsMap = f.nodes["1:22"].components ?? {};
  const result = walkComponents({
    root: page,
    pageName: "按钮",
    fileComponentsMap: componentsMap,
  });
  // The page references many instances (右箭头, 页头, 移动端填充按钮, etc.)
  assert.ok(result.nestedIds.size > 5, `expected >5 nested ids, got ${result.nestedIds.size}`);
});

test("walkComponents marks remote:true entries when the components map says so", () => {
  const f = loadFixture("page-button.json");
  const componentsMap = f.nodes["1:22"].components ?? {};
  const page = f.nodes["1:22"].document as FigmaNode;
  const result = walkComponents({
    root: page,
    pageName: "按钮",
    fileComponentsMap: componentsMap,
  });
  // "1:57" (右箭头) and "1:61" (页头) are marked remote:true in the fixture
  const arrow = result.nestedEntries.find((e) => e.key === "1:57");
  assert.ok(arrow, "expected nested entry for 1:57 (右箭头)");
  assert.equal(arrow!.remote, true);
  assert.equal(arrow!.kind, "nested");
});

test("walkComponents parses Variant name into a structured Record", () => {
  const f = loadFixture("btn-cs.json");
  const cs = f.nodes["13:47945"].document as FigmaNode;
  const componentsMap = f.nodes["13:47945"].components ?? {};
  const result = walkComponents({
    root: cs,
    pageName: "按钮",
    fileComponentsMap: componentsMap,
  });
  const set = result.entries[0]!;
  assert.deepEqual(set.variants, {
    "Property 1": ["默认", "选中", "down", "禁用", "loading"],
  });
});

test("walkComponents skips invisible nodes (matches walkNodes semantics)", () => {
  const hidden: FigmaNode = {
    id: "1:1",
    name: "ghost-set",
    type: "COMPONENT_SET",
    visible: false,
    children: [
      { id: "1:2", name: "Property 1=hidden", type: "COMPONENT", children: [] },
    ],
  };
  const result = walkComponents({
    root: hidden,
    pageName: "test",
    fileComponentsMap: {},
  });
  assert.equal(result.entries.length, 0);
});
```

- [ ] **Step 3: Run tests, expect FAIL ("Cannot find module ../src/library/fetcher.js")**

Run: `pnpm test --test-name-pattern='walkComponents' 2>&1 | tail -20`

Expected: 5 tests FAIL with module-not-found.

- [ ] **Step 4: Write `src/library/fetcher.ts` — `walkComponents` only**

```typescript
import type { FigmaNode } from "../figma/rest.js";
import { walkNodes } from "../figma/rest.js";
import type { ManifestEntry, ManifestVariants } from "./types.js";

/** File-level entry from `/v1/files/:key` response document. */
export type FileComponentInfo = {
  key: string;
  name: string;
  description?: string;
  remote?: boolean;
  componentSetId?: string;
};

export type WalkComponentsInput = {
  /** Tree to walk. Can be a CANVAS (page) or a single subtree. */
  root: FigmaNode;
  /** Top-level page name to record as `page` on all entries discovered. */
  pageName: string;
  /** The `components` map from the same nodes response — gives global `key` and `remote` flags. */
  fileComponentsMap: Record<string, FileComponentInfo>;
};

export type WalkComponentsResult = {
  /** Entries directly defined (COMPONENT/COMPONENT_SET) inside the walked tree. */
  entries: ManifestEntry[];
  /** Component ids referenced via INSTANCE but not directly defined in the walked tree. */
  nestedIds: Set<string>;
  /** Manifest entries for each id in nestedIds, looked up against fileComponentsMap. */
  nestedEntries: ManifestEntry[];
};

/**
 * Parse a variant child's name into a Record. Figma names them like:
 *   "Property 1=默认, Property 2=large"
 */
export function parseVariantName(name: string): Record<string, string> {
  const out: Record<string, string> = {};
  for (const pair of name.split(",")) {
    const eq = pair.indexOf("=");
    if (eq < 0) continue;
    const k = pair.slice(0, eq).trim();
    const v = pair.slice(eq + 1).trim();
    if (k && v) out[k] = v;
  }
  return out;
}

/**
 * Aggregate variant axes across a COMPONENT_SET's children: for each axis
 * name, collect all distinct values that appear, preserving insertion order.
 */
export function aggregateVariants(children: FigmaNode[]): ManifestVariants {
  const acc: ManifestVariants = {};
  for (const c of children) {
    if (c.type !== "COMPONENT") continue;
    const props = parseVariantName(c.name);
    for (const [axis, val] of Object.entries(props)) {
      if (!acc[axis]) acc[axis] = [];
      if (!acc[axis]!.includes(val)) acc[axis]!.push(val);
    }
  }
  return acc;
}

export function walkComponents(input: WalkComponentsInput): WalkComponentsResult {
  const { root, pageName, fileComponentsMap } = input;
  const entries: ManifestEntry[] = [];
  const directlyDefined = new Set<string>();
  const nestedIds = new Set<string>();

  walkNodes(root, (node) => {
    if (node.type === "COMPONENT_SET") {
      directlyDefined.add(node.id);
      entries.push({
        key: node.id,
        component_key: fileComponentsMap[node.id]?.key,
        name: node.name,
        kind: "set",
        page: pageName,
        variants: aggregateVariants(node.children ?? []),
        remote: fileComponentsMap[node.id]?.remote ?? false,
        description: fileComponentsMap[node.id]?.description,
      });
      // children of a set are individual COMPONENTs — they're consumed via
      // the variant axis, not as standalone entries.
      for (const c of node.children ?? []) directlyDefined.add(c.id);
    } else if (node.type === "COMPONENT") {
      // Only count standalone COMPONENTs (those not under a COMPONENT_SET).
      // `directlyDefined` is populated pre-order during the same walk, so by
      // the time we visit a child of a set the parent is already recorded.
      if (directlyDefined.has(node.id)) return;
      directlyDefined.add(node.id);
      entries.push({
        key: node.id,
        component_key: fileComponentsMap[node.id]?.key,
        name: node.name,
        kind: "single",
        page: pageName,
        remote: fileComponentsMap[node.id]?.remote ?? false,
        description: fileComponentsMap[node.id]?.description,
      });
    } else if (node.type === "INSTANCE" && node.componentId) {
      if (!directlyDefined.has(node.componentId)) {
        nestedIds.add(node.componentId);
      }
    }
  });

  // Build manifest entries for nested-only ids by looking them up in the
  // file-level components map. Remote ones become placeholders (we can't
  // get their children via REST).
  const nestedEntries: ManifestEntry[] = [];
  for (const id of nestedIds) {
    const info = fileComponentsMap[id];
    if (!info) {
      // The id was referenced but not in the file's components map — this
      // can happen for instances inside published library content. Skip;
      // place_component will surface it as unresolved at runtime.
      continue;
    }
    nestedEntries.push({
      key: id,
      component_key: info.key,
      name: info.name,
      kind: "nested",
      page: pageName,
      remote: info.remote ?? false,
      description: info.description,
    });
  }

  return { entries, nestedIds, nestedEntries };
}
```

Note: `walkNodes` (imported from `src/figma/rest.ts`) already skips `visible === false`, which the last test relies on.

A subtle concern: `walkNodes` is non-recursive (uses a stack), but it visits parents before children in document order — that's exactly what we need for `directlyDefined.has(parent)` to be true when we hit child COMPONENTs of a set. Verify by inspection of `src/figma/rest.ts:202-212`.

- [ ] **Step 5: Run tests, expect PASS**

Run: `pnpm test --test-name-pattern='walkComponents' 2>&1 | tail -30`

Expected: 5 tests PASS. If "remote:true" test fails, inspect `tests/fixtures/library/page-button.json` to confirm the components map there has `"remote": true` on `1:57`; the original fixture from `/tmp/` does.

- [ ] **Step 6: typecheck**

Run: `pnpm typecheck`
Expected: no errors.

- [ ] **Step 7: Commit**

```bash
git add tests/fixtures/library/ tests/libraryFetcher.test.ts src/library/fetcher.ts
git commit -m "feat(library): walkComponents pure function + fixtures"
```

---

## Task 4: Fetcher Orchestration (`runLibraryAdd`)

**Files:**
- Modify: `src/library/fetcher.ts` (append `runLibraryAdd` + helpers)
- Test: `tests/libraryFetcher.test.ts` (append integration-style tests using a fake fetch)

This task adds the network + disk I/O. We inject `fetch` so the tests stay hermetic.

- [ ] **Step 1: Append the test for `runLibraryAdd` to `tests/libraryFetcher.test.ts`**

Append at the bottom:

```typescript
import { runLibraryAdd } from "../src/library/fetcher.js";
import { loadLibrary } from "../src/library/manifest.js";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";

function tempRoot(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), "lib-fetch-"));
}

function makeFakeFetch(routes: Record<string, () => Response>) {
  return async (input: string | URL | Request): Promise<Response> => {
    const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
    for (const [pattern, build] of Object.entries(routes)) {
      if (url.includes(pattern)) return build();
    }
    return new Response("not stubbed: " + url, { status: 500 });
  };
}

test("runLibraryAdd writes manifest, source, and node JSONs", async () => {
  const root = tempRoot();

  // Synthetic mini-file: one page "按钮" with one COMPONENT_SET.
  const fileBody = {
    name: "TestDS",
    lastModified: "2026-05-22T00:00:00Z",
    document: {
      id: "0:1",
      name: "Document",
      type: "DOCUMENT",
      children: [
        {
          id: "1:1", name: "按钮", type: "CANVAS",
          children: [
            {
              id: "10:1", name: "btn-set", type: "COMPONENT_SET",
              children: [
                { id: "10:2", name: "Property 1=默认", type: "COMPONENT", children: [] },
                { id: "10:3", name: "Property 1=禁用", type: "COMPONENT", children: [] },
              ],
            },
          ],
        },
      ],
    },
    components: {
      "10:1": { key: "GLOBAL_KEY_HASH", name: "btn-set", remote: false },
    },
    componentSets: {},
  };

  const fetchImpl = makeFakeFetch({
    "/v1/files/TESTKEY": () => new Response(JSON.stringify(fileBody), { status: 200 }),
  });

  const result = await runLibraryAdd({
    url: "https://www.figma.com/design/TESTKEY/My-DS",
    token: "TOKEN",
    fetchImpl,
    root,
  });

  assert.equal(result.total, 1);
  assert.equal(result.slug, "testds");
  assert.deepEqual(result.by_page, { "按钮": 1 });

  const pack = loadLibrary("testds", { root });
  assert.ok(pack);
  assert.equal(pack!.manifest.length, 1);
  assert.equal(pack!.manifest[0]!.name, "btn-set");
  // node JSON dumped for this set
  const nodePath = path.join(root, "testds", "nodes", "10__1.json");
  assert.ok(fs.existsSync(nodePath), `expected node JSON at ${nodePath}`);
});

test("runLibraryAdd refuses to overwrite by default", async () => {
  const root = tempRoot();
  fs.mkdirSync(path.join(root, "existing"), { recursive: true });
  fs.writeFileSync(path.join(root, "existing", "manifest.json"), "[]");
  fs.writeFileSync(path.join(root, "existing", "source.json"), "{}");

  const fetchImpl = makeFakeFetch({});
  await assert.rejects(
    () =>
      runLibraryAdd({
        url: "https://www.figma.com/design/X/X",
        token: "T",
        fetchImpl,
        root,
        slug: "existing",
      }),
    /already exists/i,
  );
});

test("runLibraryAdd surfaces 403 with a helpful message", async () => {
  const root = tempRoot();
  const fetchImpl = makeFakeFetch({
    "/v1/files/": () => new Response("forbidden", { status: 403 }),
  });
  await assert.rejects(
    () =>
      runLibraryAdd({
        url: "https://www.figma.com/design/X/X",
        token: "T",
        fetchImpl,
        root,
      }),
    /403|token lacks access/i,
  );
});
```

- [ ] **Step 2: Run tests, expect FAIL ("runLibraryAdd is not a function")**

Run: `pnpm test --test-name-pattern='runLibraryAdd' 2>&1 | tail -30`

Expected: 3 tests FAIL.

- [ ] **Step 3: Append `runLibraryAdd` to `src/library/fetcher.ts`**

Add these imports at the top of `fetcher.ts`:

```typescript
import fs from "node:fs";
import path from "node:path";
import { parseFigmaUrl, fetchFigmaFile, type FetchImpl } from "../figma/rest.js";
import { defaultSlug, libraryDir, nodeJsonPath, vectorsDir, vectorSvgPath } from "./paths.js";
import { writeLibrary } from "./manifest.js";
import type { SourceJson } from "./types.js";
```

Then append at the bottom of the file:

```typescript
export type RunLibraryAddInput = {
  url: string;
  token: string;
  /** Optional slug — defaults to the slugified Figma file name. */
  slug?: string;
  /** Refuse / overwrite an existing library with the same slug. */
  overwrite?: boolean;
  fetchImpl?: FetchImpl;
  /** Override the libraries root — used in tests. Defaults to ~/.codedesign/libraries/. */
  root?: string;
};

export type RunLibraryAddResult = {
  slug: string;
  file_key: string;
  total: number;
  by_page: Record<string, number>;
  warnings: string[];
};

/**
 * Top-level orchestration for `library_add`:
 *   1. Parse URL → fileKey
 *   2. REST GET /v1/files/:fileKey
 *   3. Walk every page's CANVAS, accumulating entries + nested ids
 *   4. Write node JSON for each top-level entry
 *   5. Export SVG for VECTOR / BOOLEAN_OPERATION children (best effort)
 *   6. Write manifest.json + source.json
 *   7. Return summary
 */
export async function runLibraryAdd(input: RunLibraryAddInput): Promise<RunLibraryAddResult> {
  const { url, token, fetchImpl, root, overwrite = false } = input;

  const { fileKey } = parseFigmaUrl(url);
  const file = await fetchFigmaFile(fileKey, { token, fetchImpl });
  const slug = input.slug ?? defaultSlug(file.name);

  // Refuse-if-exists check.
  const dir = root ? path.join(root, slug) : libraryDir(slug);
  const manifestFile = path.join(dir, "manifest.json");
  if (fs.existsSync(manifestFile) && !overwrite) {
    throw new Error(
      `library '${slug}' already exists at ${dir}. Pass overwrite:true or choose a different slug.`,
    );
  }

  // Walk every top-level CANVAS (page). The document's direct children are pages.
  const fileComponentsMap = (file.components ?? {}) as Record<string, FileComponentInfo>;
  const allEntries: ManifestEntry[] = [];
  const allNestedEntries: ManifestEntry[] = [];
  const vectorTargets: { setKey: string; vectorId: string }[] = [];
  const by_page: Record<string, number> = {};

  for (const page of file.document.children ?? []) {
    if (page.type !== "CANVAS") continue;
    const pageName = page.name;
    const walk = walkComponents({ root: page, pageName, fileComponentsMap });
    for (const e of walk.entries) {
      allEntries.push(e);
      by_page[pageName] = (by_page[pageName] ?? 0) + 1;
    }
    for (const e of walk.nestedEntries) allNestedEntries.push(e);

    // Collect VECTOR / BOOLEAN_OPERATION ids per top-level entry for SVG export.
    for (const entry of walk.entries) {
      // Re-walk the entry subtree by id lookup.
      const entryNode = findById(page, entry.key);
      if (!entryNode) continue;
      collectVectorIds(entryNode, (vid) => vectorTargets.push({ setKey: entry.key, vectorId: vid }));
    }
  }

  // Dedupe nested entries (same id may appear from multiple pages).
  const seen = new Set<string>();
  const nestedUnique: ManifestEntry[] = [];
  for (const e of allNestedEntries) {
    if (seen.has(e.key)) continue;
    seen.add(e.key);
    nestedUnique.push(e);
  }
  const manifest: ManifestEntry[] = [...allEntries, ...nestedUnique];

  // Write per-entry node JSON. Each top-level entry needs `nodes?ids=` lookup
  // to get its full subtree (REST returns deeper detail than the bundle).
  fs.mkdirSync(path.join(dir, "nodes"), { recursive: true });
  const warnings: string[] = [];
  for (const entry of allEntries) {
    try {
      const nodeBody = await fetchNodes(fileKey, [entry.key], { token, fetchImpl });
      const subtree = nodeBody.nodes?.[entry.key]?.document;
      if (!subtree) {
        warnings.push(`node fetch returned no document for ${entry.key} (${entry.name})`);
        continue;
      }
      const dst = root
        ? path.join(root, slug, "nodes", `${entry.key.replace(/:/g, "__")}.json`)
        : nodeJsonPath(slug, entry.key);
      fs.writeFileSync(dst, JSON.stringify(subtree, null, 2), "utf8");
    } catch (err) {
      warnings.push(`failed to fetch ${entry.key}: ${(err as Error).message}`);
    }
  }

  // Batch SVG export. /v1/images supports up to 100 ids per call.
  if (vectorTargets.length > 0) {
    try {
      // Group by setKey to write into per-set vectors dir.
      const idsByOwner = new Map<string, string[]>();
      for (const v of vectorTargets) {
        const list = idsByOwner.get(v.setKey) ?? [];
        list.push(v.vectorId);
        idsByOwner.set(v.setKey, list);
      }
      for (const [setKey, ids] of idsByOwner) {
        const dirOut = root
          ? path.join(root, slug, "nodes", `${setKey.replace(/:/g, "__")}.vectors`)
          : vectorsDir(slug, setKey);
        fs.mkdirSync(dirOut, { recursive: true });
        // Batch ids in chunks of 80.
        for (let i = 0; i < ids.length; i += 80) {
          const chunk = ids.slice(i, i + 80);
          const map = await fetchImages(fileKey, chunk, { token, fetchImpl });
          for (const id of chunk) {
            const remoteUrl = map.images?.[id];
            if (!remoteUrl) continue;
            const svgRes = await (fetchImpl ?? globalThis.fetch)(remoteUrl);
            if (!svgRes.ok) continue;
            const svg = await svgRes.text();
            const dstFile = root
              ? path.join(dirOut, `${id.replace(/:/g, "__")}.svg`)
              : vectorSvgPath(slug, setKey, id);
            fs.writeFileSync(dstFile, svg, "utf8");
          }
        }
      }
    } catch (err) {
      warnings.push(`SVG export partial / failed: ${(err as Error).message}`);
    }
  }

  const source: SourceJson = {
    slug,
    file_key: fileKey,
    file_name: file.name,
    lastModified: file.lastModified ?? new Date().toISOString(),
    scanned_at: new Date().toISOString(),
    total: manifest.length,
    by_page,
  };
  writeLibrary(slug, source, manifest, { root });

  return {
    slug,
    file_key: fileKey,
    total: manifest.length,
    by_page,
    warnings,
  };
}

/** Find a descendant node by id. Linear; only used during the small per-entry SVG collection. */
function findById(root: FigmaNode, id: string): FigmaNode | null {
  let found: FigmaNode | null = null;
  walkNodes(root, (n) => {
    if (n.id === id) found = n;
  });
  return found;
}

function collectVectorIds(root: FigmaNode, push: (id: string) => void): void {
  walkNodes(root, (n) => {
    if (n.type === "VECTOR" || n.type === "BOOLEAN_OPERATION") push(n.id);
  });
}

/** REST GET /v1/files/:key/nodes?ids=... */
async function fetchNodes(
  fileKey: string,
  ids: string[],
  opts: { token: string; fetchImpl?: FetchImpl },
): Promise<{ nodes: Record<string, { document?: FigmaNode }> }> {
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
  const url = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(ids.join(","))}`;
  const res = await fetchImpl(url, { headers: { "X-Figma-Token": opts.token } });
  if (!res.ok) {
    if (res.status === 403) throw new Error(`Figma REST 403 on ${fileKey}: token lacks access`);
    throw new Error(`Figma REST ${res.status} on /nodes: ${(await res.text()).slice(0, 200)}`);
  }
  return await res.json() as { nodes: Record<string, { document?: FigmaNode }> };
}

/** REST GET /v1/images/:key?ids=... — returns map of id → signed S3 URL hosting the export. */
async function fetchImages(
  fileKey: string,
  ids: string[],
  opts: { token: string; fetchImpl?: FetchImpl },
): Promise<{ images: Record<string, string> }> {
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
  const params = new URLSearchParams({ ids: ids.join(","), format: "svg" });
  const url = `https://api.figma.com/v1/images/${fileKey}?${params}`;
  const res = await fetchImpl(url, { headers: { "X-Figma-Token": opts.token } });
  if (!res.ok) {
    throw new Error(`Figma images ${res.status}: ${(await res.text()).slice(0, 200)}`);
  }
  return await res.json() as { images: Record<string, string> };
}
```

The synthetic fixture in step 1's test doesn't have any VECTOR nodes and doesn't have a nested-id route, so the SVG export and per-entry `/nodes` calls aren't invoked in the unit test path. Real DS files exercise both — those are covered by the Tier 2 smoke in Task 8.

Wait — the per-entry `fetchNodes` IS called for the synthetic case. The fake fetch matches only `/v1/files/TESTKEY` and returns 500 for `/nodes`. Fix: extend the fake to also stub `/nodes`.

Update the first test in step 1 to add another route:

```typescript
const fetchImpl = makeFakeFetch({
  "/v1/files/TESTKEY/nodes": () =>
    new Response(JSON.stringify({
      nodes: {
        "10:1": { document: fileBody.document.children[0].children[0] },
      },
    }), { status: 200 }),
  "/v1/files/TESTKEY": () => new Response(JSON.stringify(fileBody), { status: 200 }),
});
```

Order matters here — the more specific `/nodes` route must come first, since `makeFakeFetch` walks `Object.entries` in insertion order and stops on first match.

- [ ] **Step 4: Run tests, expect PASS**

Run: `pnpm test --test-name-pattern='runLibraryAdd' 2>&1 | tail -40`

Expected: 3 tests PASS. If "writes manifest, source, and node JSONs" fails on the node-fetch step, double-check the route order in the fake fetch.

- [ ] **Step 5: typecheck**

Run: `pnpm typecheck`
Expected: no errors. If `FileComponentInfo` is unused warning, ensure the type is exported and used as `Record<string, FileComponentInfo>` rather than `any`.

- [ ] **Step 6: Commit**

```bash
git add src/library/fetcher.ts tests/libraryFetcher.test.ts
git commit -m "feat(library): runLibraryAdd orchestrates REST fetch + disk write"
```

---

## Task 5: Rebuild Layer — Basic Node Types

**Files:**
- Create: `src/library/rebuild.ts`
- Test: `tests/libraryRebuild.test.ts`

This task handles FRAME/COMPONENT/RECTANGLE/TEXT/GROUP basics. Task 6 adds INSTANCE recursion, VECTOR SVG paths, and variant selection.

`rebuild.ts` returns a JS source string — never executes Plugin code itself. That's `executeFigmaCode()`'s job at the tool layer.

- [ ] **Step 1: Write `tests/libraryRebuild.test.ts`**

```typescript
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildPluginCode } from "../src/library/rebuild.js";
import type { FigmaNode } from "../src/figma/rest.js";

function frame(id: string, name: string, extras: Partial<FigmaNode> = {}): FigmaNode {
  return {
    id, name, type: "FRAME",
    fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 } } as any],
    absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 50 },
    ...extras,
  };
}

test("buildPluginCode wraps output in async main()", () => {
  const code = buildPluginCode({
    node: frame("1:1", "root"),
    x: 10,
    y: 20,
  });
  assert.ok(code.includes("async function main()"), code);
  assert.ok(code.includes("return main();"), code);
});

test("buildPluginCode emits createFrame for FRAME type", () => {
  const code = buildPluginCode({
    node: frame("1:1", "root"),
    x: 10,
    y: 20,
  });
  assert.ok(code.includes("figma.createFrame()"));
  assert.ok(code.includes(".name = \"root\""));
});

test("buildPluginCode applies fills to a FRAME", () => {
  const code = buildPluginCode({
    node: frame("1:1", "root"),
    x: 0,
    y: 0,
  });
  // The fills array gets stringified into a JS object literal.
  assert.ok(code.includes(".fills ="));
  assert.ok(code.match(/r:\s*1/), "expected r:1 in code");
});

test("buildPluginCode emits createText with characters", () => {
  const node: FigmaNode = {
    id: "1:2",
    name: "label",
    type: "TEXT",
    characters: "Hello",
    style: { fontFamily: "Inter", fontWeight: 400, fontSize: 14 },
    fills: [{ type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 } } as any],
    absoluteBoundingBox: { x: 0, y: 0, width: 60, height: 20 },
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  assert.ok(code.includes("figma.createText()"));
  assert.ok(code.includes(`characters = "Hello"`) || code.includes(`characters="Hello"`));
  assert.ok(code.includes("loadFontAsync"));
});

test("buildPluginCode emits createRectangle for RECTANGLE", () => {
  const node: FigmaNode = {
    id: "1:3", name: "bg", type: "RECTANGLE",
    fills: [{ type: "SOLID", color: { r: 0.5, g: 0.5, b: 0.5, a: 1 } } as any],
    cornerRadius: 4,
    absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 30 },
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  assert.ok(code.includes("figma.createRectangle()"));
  assert.ok(code.includes("cornerRadius = 4"));
});

test("buildPluginCode applies Auto Layout fields to FRAME", () => {
  const node: FigmaNode = {
    ...frame("1:1", "row"),
    layoutMode: "HORIZONTAL",
    paddingLeft: 8, paddingRight: 8, paddingTop: 4, paddingBottom: 4,
    itemSpacing: 6,
    primaryAxisAlignItems: "CENTER",
    counterAxisAlignItems: "CENTER",
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  assert.ok(code.includes(`layoutMode = "HORIZONTAL"`));
  assert.ok(code.includes("paddingLeft = 8"));
  assert.ok(code.includes("itemSpacing = 6"));
  assert.ok(code.includes(`primaryAxisAlignItems = "CENTER"`));
});

test("buildPluginCode recurses into children appending to parent", () => {
  const node: FigmaNode = {
    ...frame("1:1", "outer"),
    children: [
      { ...frame("2:1", "inner-rect"), type: "RECTANGLE" },
    ],
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  // Both creators present
  assert.ok(code.includes("figma.createFrame()"));
  assert.ok(code.includes("figma.createRectangle()"));
  // Outer appends to currentPage; inner appends to outer.
  assert.ok(code.match(/appendChild\(/g)!.length >= 2);
});
```

- [ ] **Step 2: Run tests, expect FAIL**

Run: `pnpm test --test-name-pattern='buildPluginCode' 2>&1 | tail -30`

Expected: 7 tests FAIL with module-not-found.

- [ ] **Step 3: Write `src/library/rebuild.ts`**

```typescript
import type { FigmaNode } from "../figma/rest.js";

export type RebuildContext = {
  /** Mapping from componentId → ManifestEntry, used when an INSTANCE is encountered. Task 6 wires this. */
  manifestById?: Map<string, { key: string; name: string; remote: boolean }>;
  /** Pre-loaded SVG strings for VECTOR/BOOLEAN_OPERATION nodes, keyed by node id. Task 6 wires this. */
  svgById?: Map<string, string>;
  /** Recursion guard. */
  visited?: Set<string>;
  /** Max depth — Task 6 enforces this. */
  maxDepth?: number;
};

export type BuildPluginCodeInput = {
  node: FigmaNode;
  /** Anchor position on the target page. */
  x: number;
  y: number;
  /** Optional parent node id in the target file. Falls back to currentPage. */
  parent_id?: string;
  /** Variant selection — Task 6 uses this. */
  variant?: Record<string, string>;
  /** Text overrides — Task 6 uses this. */
  overrides?: Record<string, string>;
  ctx?: RebuildContext;
};

/**
 * Top-level entry. Wraps the recursive node builder in an async main() so
 * the executor (executeFigmaCode) can `await` font loads under dynamic-page
 * mode.
 */
export function buildPluginCode(input: BuildPluginCodeInput): string {
  const ctx: RebuildContext = {
    manifestById: input.ctx?.manifestById ?? new Map(),
    svgById: input.ctx?.svgById ?? new Map(),
    visited: input.ctx?.visited ?? new Set(),
    maxDepth: input.ctx?.maxDepth ?? 8,
  };

  const lines: string[] = [];
  lines.push("async function main() {");
  lines.push(parentResolveCode(input.parent_id));
  lines.push("  const fontsToLoad = new Set();");

  const rootVar = "n_root";
  emitNode(lines, input.node, rootVar, "parent", 0, ctx);

  // After the tree is built, set the root position on the page.
  lines.push(`  if (typeof ${rootVar}.x === "number") ${rootVar}.x = ${input.x};`);
  lines.push(`  if (typeof ${rootVar}.y === "number") ${rootVar}.y = ${input.y};`);

  lines.push(`  return { id: ${rootVar}.id, type: ${rootVar}.type, name: ${rootVar}.name, parent_fallback: __parent_fallback };`);
  lines.push("}");
  lines.push("return main();");
  return lines.join("\n");
}

function parentResolveCode(parentId?: string): string {
  if (!parentId) {
    return `  let parent = figma.currentPage;\n  let __parent_fallback = null;`;
  }
  // Mirrors placeAsset.ts:177-188 parent fallback semantics.
  return [
    `  const __p = await figma.getNodeByIdAsync(${JSON.stringify(parentId)});`,
    `  let parent = figma.currentPage;`,
    `  let __parent_fallback = null;`,
    `  if (!__p) { __parent_fallback = "parent_id ${parentId} not found — used currentPage"; }`,
    `  else if (typeof __p.appendChild !== "function") { __parent_fallback = "parent_id ${parentId} is a " + __p.type + " which has no appendChild — used currentPage"; }`,
    `  else { parent = __p; }`,
  ].join("\n");
}

type EmitVisited = number;

function emitNode(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  depth: number,
  ctx: RebuildContext,
): void {
  if (depth > (ctx.maxDepth ?? 8)) {
    lines.push(`  // max depth reached, dropping ${node.id} (${node.name})`);
    return;
  }
  switch (node.type) {
    case "FRAME":
    case "COMPONENT":
      emitFrameLike(lines, node, varName, parentVar, depth, ctx);
      break;
    case "RECTANGLE":
      emitRectangle(lines, node, varName, parentVar);
      break;
    case "TEXT":
      emitText(lines, node, varName, parentVar);
      break;
    // GROUP / INSTANCE / VECTOR / COMPONENT_SET handled in Task 6.
    default:
      lines.push(`  // unhandled node type ${node.type} (${node.id}) — placeholder`);
      lines.push(`  const ${varName} = figma.createFrame();`);
      lines.push(`  ${varName}.name = ${JSON.stringify("?unhandled:" + node.name)};`);
      applyFrameDimensions(lines, varName, node);
      lines.push(`  ${parentVar}.appendChild(${varName});`);
  }
}

function emitFrameLike(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  depth: number,
  ctx: RebuildContext,
): void {
  lines.push(`  const ${varName} = figma.createFrame();`);
  lines.push(`  ${varName}.name = ${JSON.stringify(node.name)};`);
  applyFrameDimensions(lines, varName, node);
  applyPaints(lines, varName, "fills", node.fills);
  applyPaints(lines, varName, "strokes", node.strokes);
  applyScalar(lines, varName, "cornerRadius", node.cornerRadius);
  applyScalar(lines, varName, "opacity", (node as any).opacity);
  applyAutoLayout(lines, varName, node);
  lines.push(`  ${parentVar}.appendChild(${varName});`);

  const children = node.children ?? [];
  for (let i = 0; i < children.length; i++) {
    const child = children[i]!;
    const childVar = `${varName}_c${i}`;
    emitNode(lines, child, childVar, varName, depth + 1, ctx);
  }
}

function emitRectangle(lines: string[], node: FigmaNode, varName: string, parentVar: string): void {
  lines.push(`  const ${varName} = figma.createRectangle();`);
  lines.push(`  ${varName}.name = ${JSON.stringify(node.name)};`);
  applyFrameDimensions(lines, varName, node);
  applyPaints(lines, varName, "fills", node.fills);
  applyPaints(lines, varName, "strokes", node.strokes);
  applyScalar(lines, varName, "cornerRadius", node.cornerRadius);
  lines.push(`  ${parentVar}.appendChild(${varName});`);
}

function emitText(lines: string[], node: FigmaNode, varName: string, parentVar: string): void {
  const family = node.style?.fontFamily ?? "Inter";
  const weightToStyle = (w?: number): string => {
    if (!w) return "Regular";
    if (w >= 700) return "Bold";
    if (w >= 500) return "Medium";
    return "Regular";
  };
  const style = weightToStyle(node.style?.fontWeight);
  const fontKey = JSON.stringify({ family, style });
  // Defer the actual load with a fallback chain.
  lines.push(`  let ${varName}Font = ${fontKey};`);
  lines.push(`  try { await figma.loadFontAsync(${varName}Font); }`);
  lines.push(`  catch { ${varName}Font = { family: "Inter", style: "Regular" }; await figma.loadFontAsync(${varName}Font); }`);
  lines.push(`  const ${varName} = figma.createText();`);
  lines.push(`  ${varName}.fontName = ${varName}Font;`);
  if (node.characters !== undefined) {
    lines.push(`  ${varName}.characters = ${JSON.stringify(node.characters)};`);
  }
  if (node.style?.fontSize) {
    lines.push(`  ${varName}.fontSize = ${node.style.fontSize};`);
  }
  applyFrameDimensions(lines, varName, node);
  applyPaints(lines, varName, "fills", node.fills);
  lines.push(`  ${parentVar}.appendChild(${varName});`);
}

function applyFrameDimensions(lines: string[], v: string, node: FigmaNode): void {
  const bb = node.absoluteBoundingBox;
  if (!bb) return;
  lines.push(`  if (typeof ${v}.resize === "function") ${v}.resize(${bb.width}, ${bb.height});`);
}

function applyPaints(lines: string[], v: string, prop: "fills" | "strokes", paints?: any[]): void {
  if (!paints || paints.length === 0) return;
  // Filter to SOLID paints (the only kind we reliably reproduce). Drop hidden ones.
  const solids = paints
    .filter((p) => p && p.type === "SOLID" && p.visible !== false && p.color)
    .map((p) => ({
      type: "SOLID",
      color: { r: p.color.r, g: p.color.g, b: p.color.b },
      opacity: p.color.a ?? p.opacity ?? 1,
    }));
  if (solids.length === 0) return;
  lines.push(`  ${v}.${prop} = ${JSON.stringify(solids)};`);
}

function applyScalar(lines: string[], v: string, prop: string, value: number | undefined): void {
  if (typeof value !== "number") return;
  lines.push(`  ${v}.${prop} = ${value};`);
}

function applyAutoLayout(lines: string[], v: string, node: FigmaNode): void {
  if (!node.layoutMode || node.layoutMode === "NONE") return;
  lines.push(`  ${v}.layoutMode = ${JSON.stringify(node.layoutMode)};`);
  const numericFields: Array<keyof FigmaNode> = [
    "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing",
  ];
  for (const k of numericFields) {
    const val = (node as any)[k];
    if (typeof val === "number") lines.push(`  ${v}.${k as string} = ${val};`);
  }
  const enumFields: Array<keyof FigmaNode> = [
    "primaryAxisAlignItems", "counterAxisAlignItems",
    "primaryAxisSizingMode", "counterAxisSizingMode",
  ];
  for (const k of enumFields) {
    const val = (node as any)[k];
    if (typeof val === "string") lines.push(`  ${v}.${k as string} = ${JSON.stringify(val)};`);
  }
}
```

Note the implementation uses `(node as any)` selectively where `FigmaNode` type doesn't yet declare Auto Layout fields — `src/figma/rest.ts:42` declares some, others are accessed dynamically. If `pnpm typecheck` complains about a specific field, extend `FigmaNode` in `src/figma/rest.ts` to declare it; do not silence with broader casts.

- [ ] **Step 4: Run tests, expect PASS**

Run: `pnpm test --test-name-pattern='buildPluginCode' 2>&1 | tail -30`

Expected: 7 tests PASS.

- [ ] **Step 5: typecheck**

Run: `pnpm typecheck`
Expected: no errors. Extend `FigmaNode` in `src/figma/rest.ts:42-75` with the missing autolayout fields (`primaryAxisAlignItems?: string;` etc.) if needed.

- [ ] **Step 6: Commit**

```bash
git add src/library/rebuild.ts tests/libraryRebuild.test.ts
# also commit FigmaNode extensions if you added any
git add src/figma/rest.ts
git commit -m "feat(library): rebuild basic node types (FRAME/RECT/TEXT) to Plugin code"
```

---

## Task 6: Rebuild Layer — INSTANCE / VECTOR / COMPONENT_SET / Variant Selection

**Files:**
- Modify: `src/library/rebuild.ts` (extend `emitNode` switch)
- Test: `tests/libraryRebuild.test.ts` (append)

Picks up where Task 5 left off. INSTANCE recursion needs the manifest map to be populated by `place_component` (Task 7); for unit tests we inject it directly.

- [ ] **Step 1: Append tests to `tests/libraryRebuild.test.ts`**

```typescript
test("buildPluginCode picks the variant by name from a COMPONENT_SET", () => {
  const node: FigmaNode = {
    id: "10:1", name: "btn-set", type: "COMPONENT_SET",
    children: [
      { id: "10:2", name: "Property 1=默认", type: "COMPONENT",
        absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 32 },
        fills: [{ type: "SOLID", color: { r: 0.1, g: 0.2, b: 1, a: 1 } } as any] },
      { id: "10:3", name: "Property 1=禁用", type: "COMPONENT",
        absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 32 },
        fills: [{ type: "SOLID", color: { r: 0.9, g: 0.9, b: 0.9, a: 1 } } as any] },
    ],
  };
  const code = buildPluginCode({
    node,
    x: 0, y: 0,
    variant: { "Property 1": "禁用" },
  });
  // The disabled variant's fill (0.9, 0.9, 0.9) must appear; the default's (0.1, 0.2, 1) must NOT.
  assert.ok(code.includes("0.9"), "expected disabled variant fill in code");
  assert.ok(!code.includes("0.2"), "expected NOT to see default variant fill");
});

test("buildPluginCode falls back when variant axis isn't supplied", () => {
  const node: FigmaNode = {
    id: "10:1", name: "btn-set", type: "COMPONENT_SET",
    children: [
      { id: "10:2", name: "Property 1=默认", type: "COMPONENT",
        absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 32 } },
    ],
  };
  // No variant → picks first COMPONENT child.
  const code = buildPluginCode({ node, x: 0, y: 0 });
  assert.ok(code.includes("figma.createFrame()"));
});

test("buildPluginCode inlines SVG for VECTOR via createNodeFromSvg", () => {
  const node: FigmaNode = {
    id: "30:5", name: "icon", type: "VECTOR",
    absoluteBoundingBox: { x: 0, y: 0, width: 16, height: 16 },
  };
  const svgById = new Map([["30:5", "<svg xmlns='http://www.w3.org/2000/svg'><path d='M0 0L16 16'/></svg>"]]);
  const code = buildPluginCode({ node, x: 0, y: 0, ctx: { svgById } });
  assert.ok(code.includes("createNodeFromSvg"));
  assert.ok(code.includes("M0 0L16 16"));
});

test("buildPluginCode emits a placeholder for VECTOR with no SVG cached", () => {
  const node: FigmaNode = {
    id: "30:5", name: "icon", type: "VECTOR",
    absoluteBoundingBox: { x: 0, y: 0, width: 16, height: 16 },
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  // No SVG → falls back to a frame placeholder.
  assert.ok(code.includes("figma.createFrame()"));
  assert.ok(code.includes("?vector-missing"));
});

test("buildPluginCode protects against circular INSTANCE references", () => {
  const node: FigmaNode = {
    id: "a:1", name: "A", type: "FRAME",
    children: [
      { id: "a:2", name: "inst", type: "INSTANCE", componentId: "a:1" } as any,
    ],
  };
  const manifestById = new Map([
    ["a:1", { key: "a:1", name: "A", remote: false }],
  ]);
  const visited = new Set<string>(["a:1"]);
  const code = buildPluginCode({ node, x: 0, y: 0, ctx: { manifestById, visited } });
  // Circular hit → placeholder, not infinite expansion.
  assert.ok(code.includes("?circular") || code.includes("?already-visited"));
});

test("buildPluginCode marks unresolved INSTANCE when componentId not in manifest", () => {
  const node: FigmaNode = {
    id: "b:1", name: "uses-external", type: "FRAME",
    children: [
      { id: "b:2", name: "inst", type: "INSTANCE", componentId: "MISSING" } as any,
    ],
  };
  const code = buildPluginCode({ node, x: 0, y: 0, ctx: { manifestById: new Map() } });
  assert.ok(code.includes("?unresolved"));
});

test("buildPluginCode emits GROUP via figma.group after children", () => {
  const node: FigmaNode = {
    id: "1:1", name: "grp", type: "GROUP",
    absoluteBoundingBox: { x: 0, y: 0, width: 50, height: 50 },
    children: [
      { id: "1:2", name: "r", type: "RECTANGLE",
        absoluteBoundingBox: { x: 0, y: 0, width: 20, height: 20 } } as any,
    ],
  };
  const code = buildPluginCode({ node, x: 0, y: 0 });
  assert.ok(code.includes("figma.group("));
});
```

- [ ] **Step 2: Run tests, expect FAIL on the new cases**

Run: `pnpm test --test-name-pattern='buildPluginCode' 2>&1 | tail -40`

Expected: the 7 prior tests PASS, the 7 new ones FAIL (they hit the `?unhandled` fallback or wrong codepath).

- [ ] **Step 3: Extend `emitNode` in `src/library/rebuild.ts`**

First extend `RebuildContext` so variant rides on context (avoids threading another arg through `emitNode`):

```typescript
export type RebuildContext = {
  manifestById?: Map<string, { key: string; name: string; remote: boolean }>;
  svgById?: Map<string, string>;
  visited?: Set<string>;
  maxDepth?: number;
  /** Variant axes → values; applied at COMPONENT_SET emit. */
  variant?: Record<string, string>;
};
```

And update `buildPluginCode` to thread `input.variant` into `ctx`:

```typescript
  const ctx: RebuildContext = {
    manifestById: input.ctx?.manifestById ?? new Map(),
    svgById: input.ctx?.svgById ?? new Map(),
    visited: input.ctx?.visited ?? new Set(),
    maxDepth: input.ctx?.maxDepth ?? 8,
    variant: input.variant ?? input.ctx?.variant,
  };
```

Then replace the existing `switch` in `emitNode` with the full extended version:

```typescript
  switch (node.type) {
    case "COMPONENT_SET":
      emitComponentSet(lines, node, varName, parentVar, depth, ctx);
      break;
    case "FRAME":
    case "COMPONENT":
      emitFrameLike(lines, node, varName, parentVar, depth, ctx);
      break;
    case "RECTANGLE":
      emitRectangle(lines, node, varName, parentVar);
      break;
    case "TEXT":
      emitText(lines, node, varName, parentVar);
      break;
    case "GROUP":
      emitGroup(lines, node, varName, parentVar, depth, ctx);
      break;
    case "INSTANCE":
      emitInstance(lines, node, varName, parentVar, depth, ctx);
      break;
    case "VECTOR":
    case "BOOLEAN_OPERATION":
      emitVector(lines, node, varName, parentVar, ctx);
      break;
    default:
      lines.push(`  const ${varName} = figma.createFrame();`);
      lines.push(`  ${varName}.name = ${JSON.stringify("?unhandled:" + node.name)};`);
      applyFrameDimensions(lines, varName, node);
      lines.push(`  ${parentVar}.appendChild(${varName});`);
  }
```

Add the four new emit functions and `parseVariantName` reuse:

```typescript
import { parseVariantName } from "./fetcher.js";

function emitComponentSet(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  depth: number,
  ctx: RebuildContext,
): void {
  const children = node.children ?? [];
  const wanted = ctx.variant ?? {};
  // Pick the first child whose parsed name equals `wanted` on every axis the caller specified.
  let chosen = children.find((c) => {
    if (c.type !== "COMPONENT") return false;
    const parsed = parseVariantName(c.name);
    for (const [axis, value] of Object.entries(wanted)) {
      if (parsed[axis] !== value) return false;
    }
    return true;
  });
  if (!chosen) chosen = children.find((c) => c.type === "COMPONENT");
  if (!chosen) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?empty-set:" + node.name)};`);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  emitNode(lines, chosen, varName, parentVar, depth, ctx);
}

function emitGroup(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  depth: number,
  ctx: RebuildContext,
): void {
  const children = node.children ?? [];
  // Build children first into a transient frame, then group + reparent.
  // figma.group(nodes, parent) requires the nodes to already exist on canvas.
  const childVars: string[] = [];
  for (let i = 0; i < children.length; i++) {
    const cv = `${varName}_g${i}`;
    emitNode(lines, children[i]!, cv, parentVar, depth + 1, ctx);
    childVars.push(cv);
  }
  if (childVars.length === 0) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?empty-group:" + node.name)};`);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  lines.push(`  const ${varName} = figma.group([${childVars.join(", ")}], ${parentVar});`);
  lines.push(`  ${varName}.name = ${JSON.stringify(node.name)};`);
}

function emitInstance(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  depth: number,
  ctx: RebuildContext,
): void {
  const cid = (node as any).componentId as string | undefined;
  if (!cid) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?no-component-id:" + node.name)};`);
    applyFrameDimensions(lines, varName, node);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  if (ctx.visited?.has(cid)) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?circular:" + node.name)};`);
    applyFrameDimensions(lines, varName, node);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  const entry = ctx.manifestById?.get(cid);
  if (!entry || entry.remote) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?unresolved:" + node.name)};`);
    applyFrameDimensions(lines, varName, node);
    lines.push(`  ${varName}.strokes = [{ type: "SOLID", color: { r: 1, g: 0.6, b: 0.6 } }];`);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  // The actual instance subtree was already inlined into the parent node's
  // `children` (Figma REST returns INSTANCE with its expanded children).
  // Treat it like a FRAME but mark visited to break loops.
  ctx.visited!.add(cid);
  try {
    emitFrameLike(lines, node, varName, parentVar, depth, ctx);
  } finally {
    ctx.visited!.delete(cid);
  }
}

function emitVector(
  lines: string[],
  node: FigmaNode,
  varName: string,
  parentVar: string,
  ctx: RebuildContext,
): void {
  const svg = ctx.svgById?.get(node.id);
  if (!svg) {
    lines.push(`  const ${varName} = figma.createFrame();`);
    lines.push(`  ${varName}.name = ${JSON.stringify("?vector-missing:" + node.name)};`);
    applyFrameDimensions(lines, varName, node);
    lines.push(`  ${parentVar}.appendChild(${varName});`);
    return;
  }
  const escaped = svg.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
  lines.push(`  const ${varName} = figma.createNodeFromSvg(\`${escaped}\`);`);
  lines.push(`  ${varName}.name = ${JSON.stringify(node.name)};`);
  applyFrameDimensions(lines, varName, node);
  lines.push(`  ${parentVar}.appendChild(${varName});`);
}
```

- [ ] **Step 4: Run all rebuild tests, expect PASS**

Run: `pnpm test --test-name-pattern='buildPluginCode' 2>&1 | tail -50`

Expected: all 14 tests PASS.

- [ ] **Step 5: typecheck**

Run: `pnpm typecheck`
Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add src/library/rebuild.ts tests/libraryRebuild.test.ts
git commit -m "feat(library): rebuild handles INSTANCE/VECTOR/GROUP/COMPONENT_SET"
```

---

## Task 7: AI Tools — `library_add`, `list_components`, `place_component`

**Files:**
- Create: `src/tools/libraryAdd.ts`
- Create: `src/tools/listComponents.ts`
- Create: `src/tools/placeComponent.ts`
- Modify: `src/repl.tsx` (register the 3 new tools)
- Test: extend `tests/tools.test.ts` (lightweight registration check)

- [ ] **Step 1: Write `src/tools/libraryAdd.ts`**

```typescript
import { z } from "zod";
import type { Tool } from "./registry.js";
import { runLibraryAdd } from "../library/fetcher.js";

const args = z.object({
  url: z.string().min(1, "url is required"),
  slug: z.string().min(1).optional(),
  overwrite: z.boolean().optional(),
});

export type LibraryAddOpts = {
  getToken: () => string | undefined;
};

export function makeLibraryAddTool(opts: LibraryAddOpts): Tool {
  return {
    schema: {
      name: "library_add",
      description:
        "Bind a user's Figma DS file to the local component library. Walks the DS via REST, collects all " +
        "COMPONENT and COMPONENT_SET nodes (including nested references), and writes a per-component node " +
        "JSON to ~/.codedesign/libraries/<slug>/. Run ONCE per DS file; the result is reused by " +
        "list_components / place_component without further network round-trips. " +
        "Slug defaults to the slugified Figma file name. " +
        "Refuses by default if the same slug exists — pass overwrite:true to replace.",
      parameters: {
        type: "object",
        properties: {
          url: { type: "string", description: "Figma file URL (figma.com/design/KEY/...). node-id is ignored; the whole file is walked." },
          slug: { type: "string", description: "library slug (defaults to slugified file name)" },
          overwrite: { type: "boolean", description: "overwrite an existing library with the same slug (default false)" },
        },
        required: ["url"],
      },
    },
    handler: async (raw) => {
      const cleaned: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
      for (const k of ["slug"]) {
        if (cleaned[k] === "" || cleaned[k] === null) delete cleaned[k];
      }
      const a = args.parse(cleaned);
      const token = opts.getToken();
      if (!token) {
        return JSON.stringify({
          error:
            "FIGMA_ACCESS_TOKEN not configured — add figma_token to ~/.codedesign/auth.toml.",
        });
      }
      try {
        const result = await runLibraryAdd({
          url: a.url,
          token,
          slug: a.slug,
          overwrite: a.overwrite,
        });
        return JSON.stringify(result);
      } catch (err) {
        return JSON.stringify({ error: (err as Error).message });
      }
    },
  };
}
```

- [ ] **Step 2: Write `src/tools/listComponents.ts`**

```typescript
import { z } from "zod";
import type { Tool } from "./registry.js";
import { searchManifest, listLibraries } from "../library/manifest.js";

const args = z.object({
  query: z.string().optional(),
  page: z.string().optional(),
  library: z.string().optional(),
  limit: z.number().int().min(1).max(50).optional(),
});

export function makeListComponentsTool(): Tool {
  return {
    schema: {
      name: "list_components",
      description:
        "Search the bound Figma component libraries. Call this BEFORE drawing any UI surface to discover " +
        "what DS components exist. Returns top matches with `key` (pass to place_component), `name`, `page` " +
        "(category), `kind` (set | single | nested), and `variants` (axis → possible values, for COMPONENT_SET). " +
        "Filter by `page` for category browsing (e.g. page=\"按钮\"), or by free-text `query` against name and variants.",
      parameters: {
        type: "object",
        properties: {
          query: { type: "string", description: "free-text query against component name / page name / variant values" },
          page: { type: "string", description: "exact page name (DS category)" },
          library: { type: "string", description: "library slug; omit to search across all installed libraries" },
          limit: { type: "integer", description: "max results 1-50 (default 20)" },
        },
      },
    },
    handler: async (raw) => {
      const cleaned: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
      for (const k of ["query", "page", "library"]) {
        if (cleaned[k] === "" || cleaned[k] === null) delete cleaned[k];
      }
      const a = args.parse(cleaned);
      const installed = listLibraries();
      if (installed.length === 0) {
        return JSON.stringify({
          error: "no libraries installed",
          hint: "run library_add(url) first to bind a Figma DS file",
        });
      }
      const result = searchManifest(a);
      return JSON.stringify(result);
    },
  };
}
```

- [ ] **Step 3: Write `src/tools/placeComponent.ts`**

```typescript
import fs from "node:fs";
import { z } from "zod";
import type { Tool } from "./registry.js";
import { executeFigmaCode } from "./figmaExecute.js";
import { loadLibrary } from "../library/manifest.js";
import { buildPluginCode } from "../library/rebuild.js";
import { nodeJsonPath, vectorsDir } from "../library/paths.js";
import type { FigmaNode } from "../figma/rest.js";
import { walkNodes } from "../figma/rest.js";
import path from "node:path";

const args = z.object({
  library: z.string().min(1),
  key: z.string().min(1),
  x: z.number(),
  y: z.number(),
  variant: z.record(z.string()).optional(),
  overrides: z.record(z.string()).optional(),
  parent_id: z.string().min(1).optional(),
});

export type PlaceComponentOpts = {
  figmaToken?: string;
};

export function makePlaceComponentTool(opts: PlaceComponentOpts): Tool {
  return {
    schema: {
      name: "place_component",
      description:
        "Place a 1:1 reproduction of a DS component onto the current Figma canvas. " +
        "Resolves the component from a library bound via library_add, generates Plugin API code that " +
        "rebuilds its node tree (frames/text/rects/vectors), and dispatches via figma_execute. " +
        "For COMPONENT_SET entries, `variant` is required (e.g. { \"Property 1\": \"默认\" }). " +
        "Returns the placed node id plus any warnings (unresolved instances, font fallbacks, parent fallback).",
      parameters: {
        type: "object",
        properties: {
          library: { type: "string", description: "library slug from list_components.library" },
          key: { type: "string", description: "component key from list_components.matches[].key" },
          x: { type: "number", description: "x position on the current page" },
          y: { type: "number", description: "y position" },
          variant: { type: "object", description: "for COMPONENT_SET: { axisName: value }, e.g. { \"Property 1\": \"默认\" }", additionalProperties: { type: "string" } },
          overrides: { type: "object", description: "text overrides keyed by child node name", additionalProperties: { type: "string" } },
          parent_id: { type: "string", description: "optional parent frame id" },
        },
        required: ["library", "key", "x", "y"],
      },
    },
    handler: async (raw) => {
      const cleaned: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
      for (const k of ["parent_id"]) {
        if (cleaned[k] === "" || cleaned[k] === null) delete cleaned[k];
      }
      const a = args.parse(cleaned);
      if (!opts.figmaToken) {
        return JSON.stringify({
          error:
            "FIGMA_ACCESS_TOKEN not configured — add figma_token to ~/.codedesign/auth.toml " +
            "and install the figma-console-mcp Desktop Bridge Plugin.",
        });
      }
      const pack = loadLibrary(a.library);
      if (!pack) {
        return JSON.stringify({ error: `library '${a.library}' not found — run library_add first` });
      }
      const entry = pack.manifest.find((e) => e.key === a.key);
      if (!entry) {
        // segment fuzzy match for candidates
        const lower = a.key.toLowerCase();
        const candidates = pack.manifest
          .filter((e) => e.key.toLowerCase().includes(lower) || e.name.toLowerCase().includes(lower))
          .slice(0, 8)
          .map((e) => ({ key: e.key, name: e.name }));
        return JSON.stringify({
          error: `component '${a.key}' not found in library '${a.library}'`,
          candidates,
        });
      }
      if (entry.remote) {
        return JSON.stringify({
          error: `component '${a.key}' is remote (referenced from a published library) — its children weren't fetched, can't rebuild`,
        });
      }

      // Read node JSON.
      const nodeFile = nodeJsonPath(a.library, entry.key);
      if (!fs.existsSync(nodeFile)) {
        return JSON.stringify({ error: `node JSON missing: ${nodeFile} — re-run library_add` });
      }
      const root = JSON.parse(fs.readFileSync(nodeFile, "utf8")) as FigmaNode;

      // Pre-load SVGs for this entry's vector children.
      const svgById = new Map<string, string>();
      const vdir = vectorsDir(a.library, entry.key);
      if (fs.existsSync(vdir)) {
        walkNodes(root, (n) => {
          if (n.type === "VECTOR" || n.type === "BOOLEAN_OPERATION") {
            const f = path.join(vdir, `${n.id.replace(/:/g, "__")}.svg`);
            if (fs.existsSync(f)) svgById.set(n.id, fs.readFileSync(f, "utf8"));
          }
        });
      }

      // Build manifestById for nested INSTANCE resolution.
      const manifestById = new Map<string, { key: string; name: string; remote: boolean }>();
      for (const e of pack.manifest) {
        manifestById.set(e.key, { key: e.key, name: e.name, remote: e.remote });
      }

      // Variant required for COMPONENT_SET.
      if (entry.kind === "set" && !a.variant) {
        return JSON.stringify({
          error: `component '${entry.name}' is a COMPONENT_SET — variant is required`,
          variants: entry.variants,
        });
      }
      if (entry.kind === "set" && a.variant && entry.variants) {
        for (const [axis, value] of Object.entries(a.variant)) {
          const allowed = entry.variants[axis];
          if (!allowed || !allowed.includes(value)) {
            return JSON.stringify({
              error: `variant '${axis}=${value}' not allowed for '${entry.name}'`,
              variants: entry.variants,
            });
          }
        }
      }

      const code = buildPluginCode({
        node: root,
        x: a.x,
        y: a.y,
        parent_id: a.parent_id,
        variant: a.variant,
        overrides: a.overrides,
        ctx: { manifestById, svgById },
      });

      return await executeFigmaCode(opts.figmaToken, code);
    },
  };
}
```

- [ ] **Step 4: Register the three tools in `src/repl.tsx`**

Open `src/repl.tsx`. Add imports near the existing tool imports (around line 30):

```typescript
import { makeLibraryAddTool } from "./tools/libraryAdd.js";
import { makeListComponentsTool } from "./tools/listComponents.js";
import { makePlaceComponentTool } from "./tools/placeComponent.js";
```

Then insert three register calls after the existing `makeVisualDiffTool` line (currently `src/repl.tsx:380`). Place them inside the same `useMemo` block, just before `return r;`:

```typescript
    // Component library — REST-walked DS + per-component reconstruction.
    r.register(makeLibraryAddTool({ getToken: () => auth.figma_token }));
    r.register(makeListComponentsTool());
    r.register(makePlaceComponentTool({ figmaToken: auth.figma_token }));
```

- [ ] **Step 5: Update `tests/tools.test.ts` with a registration smoke**

Look at the existing test file (`tests/tools.test.ts`) for its conventions. Add one test verifying the new tool names are exposed. If the file structure makes that difficult, instead add a tiny new file:

`tests/libraryTools.test.ts`:

```typescript
import { test } from "node:test";
import assert from "node:assert/strict";
import { makeLibraryAddTool } from "../src/tools/libraryAdd.js";
import { makeListComponentsTool } from "../src/tools/listComponents.js";
import { makePlaceComponentTool } from "../src/tools/placeComponent.js";

test("library_add tool has the expected schema", () => {
  const t = makeLibraryAddTool({ getToken: () => undefined });
  assert.equal(t.schema.name, "library_add");
  assert.ok(t.schema.parameters.required?.includes("url"));
});

test("list_components tool has the expected schema", () => {
  const t = makeListComponentsTool();
  assert.equal(t.schema.name, "list_components");
});

test("place_component tool has the expected schema", () => {
  const t = makePlaceComponentTool({ figmaToken: undefined });
  assert.equal(t.schema.name, "place_component");
  assert.ok(t.schema.parameters.required?.includes("library"));
  assert.ok(t.schema.parameters.required?.includes("key"));
  assert.ok(t.schema.parameters.required?.includes("x"));
  assert.ok(t.schema.parameters.required?.includes("y"));
});

test("library_add returns helpful error without token", async () => {
  const t = makeLibraryAddTool({ getToken: () => undefined });
  const out = JSON.parse(await t.handler({ url: "https://www.figma.com/design/X/X" }));
  assert.match(out.error, /FIGMA_ACCESS_TOKEN/);
});
```

- [ ] **Step 6: Run all tests, expect PASS**

Run: `pnpm test 2>&1 | tail -30`

Expected: full suite passes including the 4 new ones in `libraryTools.test.ts`.

- [ ] **Step 7: typecheck + build**

Run:
```bash
pnpm typecheck
pnpm build
```
Expected: no errors. `dist/` regenerated.

- [ ] **Step 8: Commit**

```bash
git add src/tools/libraryAdd.ts src/tools/listComponents.ts src/tools/placeComponent.ts src/repl.tsx tests/libraryTools.test.ts
git commit -m "feat(library): expose library_add / list_components / place_component AI tools"
```

---

## Task 8: Smoke Script + Documentation

**Files:**
- Create: `scripts/library-smoke.sh`
- Modify: `docs/figma-setup.md` (append "Component Library" section)

- [ ] **Step 1: Write `scripts/library-smoke.sh`**

```bash
#!/usr/bin/env bash
#
# library-smoke.sh — Tier 2 manual smoke for the component library subsystem.
#
# Usage:
#   FIGMA_ACCESS_TOKEN=figd_... scripts/library-smoke.sh
#
# Exits non-zero on the first failure. Requires the Figma Desktop Bridge
# Plugin running for the place_component step.

set -euo pipefail

DS_URL="${DS_URL:-https://www.figma.com/design/D3T0pVvPLHfxhZ5Ip4oCpF/}"
SLUG="${SLUG:-ruliu-smoke}"

if [ -z "${FIGMA_ACCESS_TOKEN:-}" ]; then
  echo "FIGMA_ACCESS_TOKEN not set"
  exit 1
fi

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

echo "[1/4] pnpm build"
pnpm build >/dev/null

# Use a hidden node REPL to drive the tools.
NODE_SCRIPT=$(mktemp)
cat > "$NODE_SCRIPT" <<EOF
import { runLibraryAdd } from "./dist/library/fetcher.js";
import { searchManifest, loadLibrary } from "./dist/library/manifest.js";

const TOKEN = process.env.FIGMA_ACCESS_TOKEN;
const URL = process.env.DS_URL;
const SLUG = process.env.SLUG;

console.error("[smoke] library_add ...");
const add = await runLibraryAdd({ url: URL, token: TOKEN, slug: SLUG, overwrite: true });
console.error("  total:", add.total, "warnings:", add.warnings.length);
if (add.total < 5) { console.error("FAIL: expected total>=5"); process.exit(2); }

console.error("[smoke] list_components query=按钮");
const search = searchManifest({ library: SLUG, query: "按钮", limit: 10 });
console.error("  matches:", search.matches.length);
if (search.matches.length < 1) { console.error("FAIL: expected matches>=1"); process.exit(2); }

console.error("[smoke] manifest snapshot:");
const pack = loadLibrary(SLUG);
console.error("  pages:", Object.keys(pack.source.by_page).slice(0, 8).join(", "));

console.error("[smoke] OK (place_component requires Figma Desktop + Bridge — run that manually via REPL)");
EOF

echo "[2/4] library_add + list_components"
DS_URL="$DS_URL" SLUG="$SLUG" FIGMA_ACCESS_TOKEN="$FIGMA_ACCESS_TOKEN" \
  node --experimental-vm-modules "$NODE_SCRIPT"

echo
echo "[3/4] Manual step required for place_component:"
echo "  1. Open a blank Figma file in Figma Desktop"
echo "  2. Run the figma-console-mcp Desktop Bridge Plugin"
echo "  3. In the codedesign REPL, type: place_component({ library: \"$SLUG\", key: \"<key from list_components>\", x: 100, y: 100, variant: { \"Property 1\": \"默认\" } })"
echo
echo "[4/4] OK"
```

Make it executable:

```bash
chmod +x scripts/library-smoke.sh
```

- [ ] **Step 2: Append "Component Library" section to `docs/figma-setup.md`**

Read the existing file first to match the style:

```bash
cat docs/figma-setup.md | tail -30
```

Then append:

```markdown

## Binding a Component Library

CodeDesign can reproduce DS components 1:1 by walking your Figma DS file via
the REST API and rebuilding each component as native Plugin API nodes on
the target canvas.

### One-time setup

In the REPL, run:

```
library_add({ "url": "https://www.figma.com/design/<KEY>/<name>" })
```

This walks every COMPONENT and COMPONENT_SET in the file, downloads SVG
exports for vector children, and writes everything under
`~/.codedesign/libraries/<slug>/`. The default slug is the slugified file
name; pass `slug:"<name>"` to override.

Re-run with `overwrite:true` to refresh after DS updates.

### Using the library

```
list_components({ "query": "按钮" })
list_components({ "page": "导航菜单" })
```

returns matching components with their `key`, page (category), and (for
COMPONENT_SET) the variant axes.

```
place_component({
  "library": "ruliu-mobile",
  "key": "13:47945",
  "x": 100, "y": 100,
  "variant": { "Property 1": "默认" }
})
```

drops a 1:1 reconstruction at the given coordinates on the current Figma
canvas. Requires the Desktop Bridge Plugin to be running.

### Limitations

- Variables (Figma's design tokens) require the `file_variables:read` OAuth
  scope which is gated to Enterprise plans. Without it, all paints are
  serialized as RGB; updates to your DS color tokens won't auto-propagate
  to placed components — re-run `place_component` to pick up new values.
- Components referenced via `remote:true` from a published Team Library
  can't be reconstructed (their children aren't accessible). They appear
  as red-bordered placeholders.
- Smoke script for end-to-end validation: `scripts/library-smoke.sh`.
```

- [ ] **Step 3: Run the smoke script**

```bash
FIGMA_ACCESS_TOKEN="$YOUR_PAT" scripts/library-smoke.sh
```

Expected:
- Step [1/4]: build succeeds
- Step [2/4]: prints `total:` ≥ 50, `matches:` ≥ 1
- Step [3/4]: prints manual step instructions
- Step [4/4]: prints OK

If you don't have a PAT handy or want to skip the manual Figma Desktop step, the smoke as-is exits before `place_component` and only validates `library_add` + `list_components`.

- [ ] **Step 4: typecheck + full test suite**

Run:
```bash
pnpm typecheck
pnpm test
```

Expected: clean.

- [ ] **Step 5: Commit**

```bash
git add scripts/library-smoke.sh docs/figma-setup.md
git commit -m "docs(library): add smoke script + figma-setup section for component library"
```

---

## Verification Checklist (after all tasks complete)

- [ ] `pnpm test` — full suite green
- [ ] `pnpm typecheck` — no errors
- [ ] `pnpm build` — `dist/` regenerated, `dist/library/` and `dist/tools/{libraryAdd,listComponents,placeComponent}.js` present
- [ ] `scripts/library-smoke.sh` — total ≥ 50, matches ≥ 1
- [ ] Manual: REPL `place_component` against a blank Figma file produces a frame visually matching the DS source (per the visual_diff workflow in §7.3 of the spec, run manually for now)
- [ ] `git log --oneline` shows 8 commits, one per task

## Spec Coverage Map

| Spec section | Covered by |
|---|---|
| §2.1 module layout | Tasks 1-7 (all files created) |
| §2.2 reuse `figma/rest.ts`, `executeFigmaCode`, knowledge-pack-like layout | Tasks 3, 4, 7 |
| §3 抓取层 | Tasks 3-4 (walkComponents + runLibraryAdd) |
| §3.3 manifest schema | Task 1 types, Task 2 round-trip test |
| §4 检索层 | Task 2 (`searchManifest`) + Task 7 (`list_components` tool) |
| §5 重建层 | Tasks 5-6 (`buildPluginCode`) + Task 7 (`place_component` tool) |
| §5.4 VECTOR via SVG | Task 4 (SVG export) + Task 6 (`emitVector`) |
| §5.5 variant 选择 | Task 6 (`emitComponentSet`) |
| §5.6 visited / max depth | Task 6 (`emitInstance` + ctx.visited) |
| §6 错误处理 | Tasks 2, 4, 7 (each fold their failure modes inline) |
| §7 测试策略 Tier 1 | Tasks 1, 2, 3, 4, 5, 6 unit tests |
| §7 Tier 2 smoke | Task 8 (`library-smoke.sh`) |
| §7 Tier 3 visual_diff | Reuses existing `tools/visualDiff.ts` — manual workflow noted in `docs/figma-setup.md` |
| §8 MVP M1 | All 8 tasks together produce the M1 deliverable |

M2 (multi-component composition) and M3 (full-page generation) are out of scope for this plan — they're prompt and coverage improvements on top of M1's tool surface.
