canicode 0.9.1 → 0.10.1

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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, renameSync, chmodSync, copyFileSync } from 'fs';
3
- import { join, resolve, dirname, basename } from 'path';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, copyFileSync, readdirSync, renameSync, chmodSync } from 'fs';
3
+ import { join, resolve, dirname, basename, relative } from 'path';
4
4
  import pixelmatch from 'pixelmatch';
5
5
  import { PNG } from 'pngjs';
6
6
  import { createRequire } from 'module';
@@ -10,7 +10,8 @@ import { randomUUID } from 'crypto';
10
10
  import { homedir } from 'os';
11
11
  import { z } from 'zod';
12
12
  import { writeFile, readFile } from 'fs/promises';
13
- import { pathToFileURL } from 'url';
13
+ import { createInterface } from 'readline/promises';
14
+ import { pathToFileURL, fileURLToPath } from 'url';
14
15
 
15
16
  var __defProp = Object.defineProperty;
16
17
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1369,7 +1370,14 @@ var EVENTS = {
1369
1370
  MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
1370
1371
  // CLI
1371
1372
  CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
1372
- CLI_INIT: `${EVENT_PREFIX}cli_init`
1373
+ CLI_INIT: `${EVENT_PREFIX}cli_init`,
1374
+ // Roundtrip (ADR-012)
1375
+ // Wiring point for the roundtrip helper's `telemetry` callback. No Node-side
1376
+ // orchestrator reads this yet — the helper ships in a sandbox-pure IIFE that
1377
+ // cannot import `core/monitoring` directly, so the event fires through a
1378
+ // caller-supplied callback. Define the typed name here so a future consumer
1379
+ // has a single place to wire it up.
1380
+ ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
1373
1381
  };
1374
1382
 
1375
1383
  // src/core/monitoring/capture.ts
@@ -1511,8 +1519,8 @@ CANICODE DOCUMENTATION (v${pkg.version})
1511
1519
 
1512
1520
  canicode docs setup Full setup guide (CLI, MCP, Skills)
1513
1521
  canicode docs config Config override guide + example
1514
- canicode docs implement Design-to-code package guide
1515
1522
  canicode docs scoring Scoring model explanation
1523
+ canicode docs rules Pointer to rule list (canicode list-rules)
1516
1524
 
1517
1525
  Full documentation: github.com/let-sunny/canicode#readme
1518
1526
  `.trimStart());
@@ -1530,7 +1538,7 @@ CANICODE SETUP GUIDE
1530
1538
 
1531
1539
  Setup:
1532
1540
  canicode init --token figd_xxxxxxxxxxxxx
1533
- (saved to ~/.canicode/config.json, reports go to ~/.canicode/reports/)
1541
+ (saves token + installs skills \u2014 see section 2 for --no-skills)
1534
1542
 
1535
1543
  Use:
1536
1544
  canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
@@ -1545,14 +1553,27 @@ CANICODE SETUP GUIDE
1545
1553
  ~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
1546
1554
 
1547
1555
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1548
- 2. CLAUDE CODE SKILL (requires FIGMA_TOKEN)
1556
+ 2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
1549
1557
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1550
1558
 
1551
1559
  Setup:
1552
- cp -r .claude/skills/canicode /your-project/.claude/skills/
1560
+ canicode init --token figd_xxxxxxxxxxxxx
1561
+ (installs three skills into ./.claude/skills/ alongside the token)
1562
+
1563
+ Installed skills:
1564
+ canicode Lightweight CLI wrapper
1565
+ canicode-gotchas Standalone gotcha survey
1566
+ canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
1567
+
1568
+ Flags:
1569
+ --global Install to ~/.claude/skills/ instead of ./.claude/skills/
1570
+ --no-skills Skip skill install (token only \u2014 legacy behavior)
1571
+ --force Overwrite existing skill files without prompting
1553
1572
 
1554
1573
  Use (in Claude Code):
1555
1574
  /canicode https://www.figma.com/design/ABC123/MyDesign?node-id=1-234
1575
+ /canicode-gotchas <url> Run a gotcha survey
1576
+ /canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
1556
1577
 
1557
1578
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1558
1579
  TOKEN PRIORITY
@@ -1618,7 +1639,7 @@ OPTIONS
1618
1639
  --output <dir> Output directory (default: /tmp/canicode-visual-compare)
1619
1640
  --width <px> Logical viewport width in CSS px (omit = infer from Figma PNG \xF7 export scale)
1620
1641
  --height <px> Logical viewport height in CSS px (omit = infer from Figma PNG \xF7 export scale)
1621
- --figma-scale <n> Figma Images API scale (default: 2, matches save-fixture @2x PNGs)
1642
+ --figma-scale <n> Figma Images API scale (default: 2, matches calibrate-save-fixture @2x PNGs)
1622
1643
 
1623
1644
  OUTPUT FILES
1624
1645
  /tmp/canicode-visual-compare/
@@ -1678,46 +1699,17 @@ USE CASES
1678
1699
  Quick design structure inspection
1679
1700
  `.trimStart());
1680
1701
  }
1681
- function printDocsImplement() {
1702
+ function printDocsRules() {
1682
1703
  console.log(`
1683
- DESIGN-TO-CODE IMPLEMENTATION GUIDE
1704
+ RULES
1684
1705
 
1685
- Prepare everything an AI needs to implement a Figma design as code.
1706
+ Run 'canicode list-rules' for the full table of rule IDs, scores, and severity.
1686
1707
 
1687
- USAGE
1688
- canicode implement <figma-url-or-fixture> [options]
1708
+ Customize per-rule via config:
1709
+ canicode docs config
1689
1710
 
1690
- OPTIONS
1691
- --prompt <path> Custom prompt file (default: built-in HTML+CSS)
1692
- --image-scale <n> Image export scale: 2 for PC (default), 3 for mobile
1693
- --output <dir> Output directory (default: ./canicode-implement/)
1694
- --token <token> Figma API token (for live URLs)
1695
-
1696
- OUTPUT
1697
- canicode-implement/
1698
- analysis.json Analysis report with issues and scores
1699
- design-tree.txt DOM-like tree with CSS styles (~N tokens)
1700
- images/ PNG assets with human-readable names (hero-banner@2x.png)
1701
- vectors/ SVG assets for vector nodes
1702
- PROMPT.md Stack-specific code generation prompt
1703
-
1704
- WORKFLOW
1705
- 1. Run: canicode implement ./my-fixture --prompt ./my-react-prompt.md
1706
- 2. Feed design-tree.txt + PROMPT.md to your AI assistant
1707
- 3. AI generates code matching the design pixel-perfectly
1708
- 4. Verify with: canicode visual-compare ./output.html --figma-url <url>
1709
-
1710
- CUSTOM PROMPT
1711
- Default prompt generates HTML+CSS. For your own stack:
1712
- 1. Write a prompt file (e.g. my-react-prompt.md)
1713
- 2. Pass it: canicode implement ./fixture --prompt ./my-react-prompt.md
1714
- The design-tree.txt format is stack-agnostic \u2014 your prompt just needs
1715
- to describe how to convert it to your target framework.
1716
-
1717
- IMAGE SCALE
1718
- --image-scale 2 PC/desktop (default) \u2014 @2x retina
1719
- --image-scale 3 Mobile \u2014 @3x retina
1720
- SVG vectors are scale-independent and always included.
1711
+ Full reference:
1712
+ https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md
1721
1713
  `.trimStart());
1722
1714
  }
1723
1715
  function printDocsScoring() {
@@ -1740,8 +1732,8 @@ var DOCS_TOPICS = {
1740
1732
  install: printDocsSetup,
1741
1733
  // alias
1742
1734
  config: printDocsConfig,
1743
- implement: printDocsImplement,
1744
1735
  scoring: printDocsScoring,
1736
+ rules: printDocsRules,
1745
1737
  "visual-compare": printDocsVisualCompare,
1746
1738
  "design-tree": printDocsDesignTree
1747
1739
  };
@@ -1936,6 +1928,44 @@ function getConfigsWithPreset(preset) {
1936
1928
  }
1937
1929
  return configs;
1938
1930
  }
1931
+ var RULE_ANNOTATION_PROPERTIES = {
1932
+ "missing-size-constraint": {
1933
+ default: [{ type: "width" }, { type: "height" }]
1934
+ },
1935
+ "irregular-spacing": {
1936
+ bySubType: {
1937
+ gap: [{ type: "itemSpacing" }],
1938
+ padding: [{ type: "padding" }]
1939
+ }
1940
+ },
1941
+ "fixed-size-in-auto-layout": {
1942
+ default: [{ type: "width" }, { type: "height" }, { type: "layoutMode" }]
1943
+ },
1944
+ "raw-value": {
1945
+ bySubType: {
1946
+ color: [{ type: "fills" }],
1947
+ font: [
1948
+ { type: "fontSize" },
1949
+ { type: "fontFamily" },
1950
+ { type: "fontWeight" },
1951
+ { type: "lineHeight" }
1952
+ ],
1953
+ spacing: [{ type: "itemSpacing" }, { type: "padding" }]
1954
+ }
1955
+ },
1956
+ "absolute-position-in-auto-layout": {
1957
+ default: [{ type: "layoutMode" }]
1958
+ }
1959
+ };
1960
+ function getAnnotationProperties(ruleId, subType) {
1961
+ const entry = RULE_ANNOTATION_PROPERTIES[ruleId];
1962
+ if (!entry) return void 0;
1963
+ if (subType !== void 0 && entry.bySubType) {
1964
+ const match = entry.bySubType[subType];
1965
+ if (match) return match;
1966
+ }
1967
+ return entry.default;
1968
+ }
1939
1969
  function getRuleOption(ruleId, optionKey, defaultValue) {
1940
1970
  const config2 = RULE_CONFIGS[ruleId];
1941
1971
  if (!config2.options) return defaultValue;
@@ -3036,6 +3066,10 @@ defineRule({
3036
3066
  });
3037
3067
 
3038
3068
  // src/core/rules/naming/index.ts
3069
+ function capitalize(s) {
3070
+ if (!s) return s;
3071
+ return s.charAt(0).toUpperCase() + s.slice(1);
3072
+ }
3039
3073
  function detectNamingConvention(name) {
3040
3074
  if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
3041
3075
  if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
@@ -3161,6 +3195,7 @@ var inconsistentNamingConventionCheck = (node, context) => {
3161
3195
  ruleId: inconsistentNamingConventionDef.id,
3162
3196
  nodeId: node.id,
3163
3197
  nodePath: context.path.join(" > "),
3198
+ suggestedName: suggested,
3164
3199
  ...inconsistentNamingMsg(node.name, nodeConvention, dominantConvention, suggested)
3165
3200
  };
3166
3201
  }
@@ -3198,6 +3233,7 @@ var nonStandardNamingCheck = (node, context) => {
3198
3233
  subType: "state-name",
3199
3234
  nodeId: node.id,
3200
3235
  nodePath: context.path.join(" > "),
3236
+ suggestedName: capitalize(suggestion),
3201
3237
  ...nonStandardNamingMsg.stateName(node.name, opt, suggestion)
3202
3238
  };
3203
3239
  }
@@ -3889,8 +3925,97 @@ async function loadFromApi(fileKey, nodeId, token) {
3889
3925
  return { file, nodeId };
3890
3926
  }
3891
3927
 
3928
+ // src/core/adapters/instance-id-parser.ts
3929
+ function isInstanceChildNodeId(nodeId) {
3930
+ return nodeId.startsWith("I") && nodeId.includes(";");
3931
+ }
3932
+ function parseInstanceChildNodeId(nodeId) {
3933
+ if (!isInstanceChildNodeId(nodeId)) return null;
3934
+ const segments = nodeId.split(";");
3935
+ if (segments.length < 2) return null;
3936
+ const parentInstanceId = segments[0].replace(/^I/, "");
3937
+ const sourceNodeId = segments[segments.length - 1];
3938
+ if (!parentInstanceId || !sourceNodeId) return null;
3939
+ return { parentInstanceId, sourceNodeId };
3940
+ }
3941
+
3942
+ // src/core/gotcha/apply-context.ts
3943
+ var STRATEGY_BY_RULE = {
3944
+ // Strategy A — property modification
3945
+ "no-auto-layout": "property-mod",
3946
+ "fixed-size-in-auto-layout": "property-mod",
3947
+ "missing-size-constraint": "property-mod",
3948
+ "irregular-spacing": "property-mod",
3949
+ "non-semantic-name": "property-mod",
3950
+ // Strategy B — structural modification (needs user confirmation)
3951
+ "non-layout-container": "structural-mod",
3952
+ "deep-nesting": "structural-mod",
3953
+ "missing-component": "structural-mod",
3954
+ "detached-instance": "structural-mod",
3955
+ // Strategy C — annotation only
3956
+ "absolute-position-in-auto-layout": "annotation",
3957
+ "variant-structure-mismatch": "annotation",
3958
+ // Strategy D — auto-fix lower-severity issues from analyze output
3959
+ "non-standard-naming": "auto-fix",
3960
+ "inconsistent-naming-convention": "auto-fix",
3961
+ "raw-value": "auto-fix",
3962
+ "missing-interaction-state": "auto-fix",
3963
+ "missing-prototype": "auto-fix"
3964
+ };
3965
+ function resolveTargetProperty(ruleId, subType) {
3966
+ switch (ruleId) {
3967
+ case "no-auto-layout":
3968
+ return ["layoutMode", "itemSpacing"];
3969
+ case "fixed-size-in-auto-layout":
3970
+ if (subType === "horizontal") return "layoutSizingHorizontal";
3971
+ return ["layoutSizingHorizontal", "layoutSizingVertical"];
3972
+ case "missing-size-constraint":
3973
+ if (subType === "wrap") return "minWidth";
3974
+ if (subType === "max-width") return "maxWidth";
3975
+ return ["minWidth", "maxWidth"];
3976
+ case "irregular-spacing":
3977
+ if (subType === "gap") return "itemSpacing";
3978
+ return ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
3979
+ case "non-semantic-name":
3980
+ return "name";
3981
+ case "non-layout-container":
3982
+ return "layoutMode";
3983
+ case "non-standard-naming":
3984
+ case "inconsistent-naming-convention":
3985
+ return "name";
3986
+ case "deep-nesting":
3987
+ case "missing-component":
3988
+ case "detached-instance":
3989
+ case "absolute-position-in-auto-layout":
3990
+ case "variant-structure-mismatch":
3991
+ case "raw-value":
3992
+ case "missing-interaction-state":
3993
+ case "missing-prototype":
3994
+ return void 0;
3995
+ }
3996
+ }
3997
+ function computeApplyContext(violation, instanceContext) {
3998
+ const ruleId = violation.ruleId;
3999
+ const applyStrategy = STRATEGY_BY_RULE[ruleId] ?? "annotation";
4000
+ const targetProperty = resolveTargetProperty(ruleId, violation.subType);
4001
+ const annotationProperties = getAnnotationProperties(
4002
+ ruleId,
4003
+ violation.subType
4004
+ );
4005
+ const parsed = parseInstanceChildNodeId(violation.nodeId);
4006
+ const isInstanceChild = parsed !== null || isInstanceChildNodeId(violation.nodeId);
4007
+ const sourceChildId = instanceContext?.sourceNodeId ?? parsed?.sourceNodeId;
4008
+ return {
4009
+ applyStrategy,
4010
+ ...targetProperty !== void 0 ? { targetProperty } : {},
4011
+ ...annotationProperties !== void 0 ? { annotationProperties } : {},
4012
+ isInstanceChild,
4013
+ ...sourceChildId !== void 0 ? { sourceChildId } : {}
4014
+ };
4015
+ }
4016
+
3892
4017
  // package.json
3893
- var version2 = "0.9.1";
4018
+ var version2 = "0.10.1";
3894
4019
 
3895
4020
  // src/core/engine/scoring.ts
3896
4021
  function computeTotalScorePerCategory(configs) {
@@ -3919,6 +4044,9 @@ function calculateGrade(percentage) {
3919
4044
  if (percentage >= 50) return "D";
3920
4045
  return "F";
3921
4046
  }
4047
+ function isReadyForCodeGen(grade) {
4048
+ return grade === "S" || grade === "A+" || grade === "A";
4049
+ }
3922
4050
  function clamp(value, min, max) {
3923
4051
  return Math.max(min, Math.min(max, value));
3924
4052
  }
@@ -4066,14 +4194,24 @@ function buildResultJson(fileName, result, scores, options) {
4066
4194
  const id = issue.violation.ruleId;
4067
4195
  issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
4068
4196
  }
4069
- const issues = result.issues.map((issue) => ({
4070
- ruleId: issue.violation.ruleId,
4071
- ...issue.violation.subType && { subType: issue.violation.subType },
4072
- severity: issue.config.severity,
4073
- nodeId: issue.violation.nodeId,
4074
- nodePath: issue.violation.nodePath,
4075
- message: issue.violation.message
4076
- }));
4197
+ const issues = result.issues.map((issue) => {
4198
+ const applyContext = computeApplyContext(issue.violation);
4199
+ const suggestedName = issue.violation.suggestedName;
4200
+ return {
4201
+ ruleId: issue.violation.ruleId,
4202
+ ...issue.violation.subType && { subType: issue.violation.subType },
4203
+ severity: issue.config.severity,
4204
+ nodeId: issue.violation.nodeId,
4205
+ nodePath: issue.violation.nodePath,
4206
+ message: issue.violation.message,
4207
+ applyStrategy: applyContext.applyStrategy,
4208
+ ...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
4209
+ ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
4210
+ ...suggestedName !== void 0 ? { suggestedName } : {},
4211
+ isInstanceChild: applyContext.isInstanceChild,
4212
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
4213
+ };
4214
+ });
4077
4215
  const json = {
4078
4216
  version: version2,
4079
4217
  analyzedAt: result.analyzedAt,
@@ -4082,6 +4220,8 @@ function buildResultJson(fileName, result, scores, options) {
4082
4220
  nodeCount: result.nodeCount,
4083
4221
  maxDepth: result.maxDepth,
4084
4222
  issueCount: result.issues.length,
4223
+ isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
4224
+ blockingIssueCount: scores.summary.blocking,
4085
4225
  scores: {
4086
4226
  overall: scores.overall,
4087
4227
  categories: scores.byCategory
@@ -5470,20 +5610,369 @@ Report saved: ${outputPath}`);
5470
5610
  }
5471
5611
  });
5472
5612
  }
5473
- var SaveFixtureOptionsSchema = z.object({
5474
- output: z.string().optional(),
5475
- api: z.boolean().optional(),
5613
+ z.object({
5614
+ ruleId: z.string(),
5615
+ question: z.string(),
5616
+ hint: z.string(),
5617
+ example: z.string()
5618
+ });
5619
+ var GOTCHA_QUESTIONS = {
5620
+ // ── Pixel Critical (blocking) ──
5621
+ "no-auto-layout": {
5622
+ ruleId: "no-auto-layout",
5623
+ question: 'Frame "{nodeName}" has no Auto Layout. How should this area be laid out?',
5624
+ hint: "Describe the flex direction, gap, and alignment",
5625
+ example: "Vertical flex, gap 16px, items centered"
5626
+ },
5627
+ "absolute-position-in-auto-layout": {
5628
+ ruleId: "absolute-position-in-auto-layout",
5629
+ question: '"{nodeName}" uses absolute positioning inside an Auto Layout parent. Is this an intentional overlay, or should it flow with the layout?',
5630
+ hint: "Specify if this is a badge/overlay, or should be part of the normal flow",
5631
+ example: "This is a notification badge \u2014 position absolute, top-right corner"
5632
+ },
5633
+ "non-layout-container": {
5634
+ ruleId: "non-layout-container",
5635
+ question: '"{nodeName}" is a Group/Section used as a layout container. What layout structure should it have?',
5636
+ hint: "Describe the intended layout: flex direction, wrap, gap",
5637
+ example: "Horizontal flex, gap 12px, wrap on mobile"
5638
+ },
5639
+ // ── Responsive Critical (risk) ──
5640
+ "fixed-size-in-auto-layout": {
5641
+ ruleId: "fixed-size-in-auto-layout",
5642
+ question: '"{nodeName}" has a fixed size inside Auto Layout. Should it be responsive?',
5643
+ hint: "Specify which axis should be flexible (width, height, or both)",
5644
+ example: "Width should FILL the parent, height can stay fixed"
5645
+ },
5646
+ "missing-size-constraint": {
5647
+ ruleId: "missing-size-constraint",
5648
+ question: '"{nodeName}" uses FILL sizing without min/max constraints. What are the size boundaries?',
5649
+ hint: "Provide min-width, max-width, or both",
5650
+ example: "min-width 320px, max-width 1200px"
5651
+ },
5652
+ // ── Code Quality (risk) ──
5653
+ "missing-component": {
5654
+ ruleId: "missing-component",
5655
+ question: '"{nodeName}" appears to be a repeated structure. Should it be a reusable component?',
5656
+ hint: "Describe if this should be extracted as a component and what props it needs",
5657
+ example: "Yes, extract as ProductCard component with title, image, and price props"
5658
+ },
5659
+ "detached-instance": {
5660
+ ruleId: "detached-instance",
5661
+ question: '"{nodeName}" looks like a detached component instance. Should it use the original component or is it a new variant?',
5662
+ hint: "Specify whether to restore the component link or create a new variant",
5663
+ example: "This is a new variant \u2014 create a 'compact' variant of the original component"
5664
+ },
5665
+ "variant-structure-mismatch": {
5666
+ ruleId: "variant-structure-mismatch",
5667
+ question: '"{nodeName}" has variants with different child structures. Which structure is the canonical one?',
5668
+ hint: "Describe which variant has the correct structure, or if they should all match",
5669
+ example: "Default variant is canonical \u2014 other variants should toggle child visibility instead of adding/removing elements"
5670
+ },
5671
+ "deep-nesting": {
5672
+ ruleId: "deep-nesting",
5673
+ question: '"{nodeName}" is deeply nested. Can some intermediate layers be flattened or extracted?',
5674
+ hint: "Identify which wrapper layers are unnecessary or should become sub-components",
5675
+ example: "The inner wrapper is just for spacing \u2014 flatten it and use padding instead"
5676
+ },
5677
+ // ── Token Management ──
5678
+ "raw-value": {
5679
+ ruleId: "raw-value",
5680
+ question: '"{nodeName}" uses raw values without design tokens. What tokens should be used?',
5681
+ hint: "Specify the token names or variable references for colors, fonts, spacing, etc.",
5682
+ example: "Use $color-primary for the fill, $font-body for the text style"
5683
+ },
5684
+ "irregular-spacing": {
5685
+ ruleId: "irregular-spacing",
5686
+ question: '"{nodeName}" has spacing values that are off the design grid. What should the correct spacing be?',
5687
+ hint: "Provide the intended spacing value aligned to the grid system",
5688
+ example: "Gap should be 16px (4pt grid), not 15px"
5689
+ },
5690
+ // ── Interaction ──
5691
+ "missing-interaction-state": {
5692
+ ruleId: "missing-interaction-state",
5693
+ question: '"{nodeName}" appears interactive but is missing state variants. What interaction states are needed?',
5694
+ hint: "List the needed states: Hover, Active, Disabled, Focus",
5695
+ example: "Needs Hover (darken 10%) and Disabled (opacity 50%, no pointer events)"
5696
+ },
5697
+ "missing-prototype": {
5698
+ ruleId: "missing-prototype",
5699
+ question: '"{nodeName}" looks interactive but has no prototype interaction. What should happen on click/interaction?',
5700
+ hint: "Describe the interaction behavior: navigation, overlay, state change, etc.",
5701
+ example: "On click, navigate to the product detail page"
5702
+ },
5703
+ // ── Semantic ──
5704
+ "non-standard-naming": {
5705
+ ruleId: "non-standard-naming",
5706
+ question: '"{nodeName}" uses non-standard state names. What naming convention should be followed?',
5707
+ hint: "Specify the expected state name format (e.g., Hover, Disabled, Active)",
5708
+ example: 'Use "Hover" instead of "hover_v1", "Disabled" instead of "off"'
5709
+ },
5710
+ "non-semantic-name": {
5711
+ ruleId: "non-semantic-name",
5712
+ question: '"{nodeName}" has a non-semantic name. What is the purpose of this element?',
5713
+ hint: "Provide a descriptive name that reflects the element's role in the UI",
5714
+ example: 'Rename "Frame 12" to "HeroSection" or "ProductGrid"'
5715
+ },
5716
+ "inconsistent-naming-convention": {
5717
+ ruleId: "inconsistent-naming-convention",
5718
+ question: '"{nodeName}" uses a different naming convention than its siblings. Which convention should be used?',
5719
+ hint: "Choose one: camelCase, kebab-case, PascalCase, or Title Case",
5720
+ example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
5721
+ }
5722
+ };
5723
+
5724
+ // src/core/gotcha/survey-generator.ts
5725
+ var NODE_PATH_SEPARATOR = " > ";
5726
+ function generateGotchaSurvey(result, scores) {
5727
+ const grade = scores.overall.grade;
5728
+ const relevantIssues = result.issues.filter(
5729
+ (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
5730
+ );
5731
+ const deduped = deduplicateSiblingIssues(relevantIssues);
5732
+ const sorted = stableSortBySeverity(deduped);
5733
+ const questions = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
5734
+ return {
5735
+ designGrade: grade,
5736
+ isReadyForCodeGen: isReadyForCodeGen(grade),
5737
+ questions
5738
+ };
5739
+ }
5740
+ function deduplicateSiblingIssues(issues) {
5741
+ const seen = /* @__PURE__ */ new Set();
5742
+ const result = [];
5743
+ for (const issue of issues) {
5744
+ const parentPath = getParentPath(issue.violation.nodePath);
5745
+ const key = `${parentPath}||${issue.violation.ruleId}`;
5746
+ if (!seen.has(key)) {
5747
+ seen.add(key);
5748
+ result.push(issue);
5749
+ }
5750
+ }
5751
+ return result;
5752
+ }
5753
+ function getParentPath(nodePath) {
5754
+ const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
5755
+ if (lastSep === -1) return "";
5756
+ return nodePath.slice(0, lastSep);
5757
+ }
5758
+ function getNodeName(nodePath) {
5759
+ const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
5760
+ if (lastSep === -1) return nodePath;
5761
+ return nodePath.slice(lastSep + NODE_PATH_SEPARATOR.length);
5762
+ }
5763
+ function stableSortBySeverity(issues) {
5764
+ const blocking = [];
5765
+ const risk = [];
5766
+ for (const issue of issues) {
5767
+ if (issue.config.severity === "blocking") {
5768
+ blocking.push(issue);
5769
+ } else {
5770
+ risk.push(issue);
5771
+ }
5772
+ }
5773
+ return [...blocking, ...risk];
5774
+ }
5775
+ function mapToQuestion(issue, file) {
5776
+ const ruleId = issue.violation.ruleId;
5777
+ const template = GOTCHA_QUESTIONS[ruleId];
5778
+ if (!template) return null;
5779
+ const nodeName = getNodeName(issue.violation.nodePath);
5780
+ const instanceContext = buildInstanceContext(issue.violation.nodeId, file);
5781
+ const applyContext = computeApplyContext(
5782
+ issue.violation,
5783
+ instanceContext ?? void 0
5784
+ );
5785
+ const suggestedName = issue.violation.suggestedName;
5786
+ return {
5787
+ nodeId: issue.violation.nodeId,
5788
+ nodeName,
5789
+ ruleId,
5790
+ severity: issue.config.severity,
5791
+ question: template.question.replace("{nodeName}", nodeName),
5792
+ hint: template.hint,
5793
+ example: template.example,
5794
+ ...instanceContext ? { instanceContext } : {},
5795
+ applyStrategy: applyContext.applyStrategy,
5796
+ ...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
5797
+ ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
5798
+ ...suggestedName !== void 0 ? { suggestedName } : {},
5799
+ isInstanceChild: applyContext.isInstanceChild,
5800
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
5801
+ };
5802
+ }
5803
+ function buildInstanceContext(nodeId, file) {
5804
+ const parts = parseInstanceChildNodeId(nodeId);
5805
+ if (!parts) return null;
5806
+ const parentInstance = findNodeById2(file.document, parts.parentInstanceId);
5807
+ const componentId = parentInstance?.componentId;
5808
+ const componentName = componentId ? file.components[componentId]?.name : void 0;
5809
+ return {
5810
+ parentInstanceNodeId: parts.parentInstanceId,
5811
+ sourceNodeId: parts.sourceNodeId,
5812
+ ...componentId ? { sourceComponentId: componentId } : {},
5813
+ ...componentName ? { sourceComponentName: componentName } : {}
5814
+ };
5815
+ }
5816
+ function findNodeById2(node, id) {
5817
+ if (node.id === id) return node;
5818
+ if (node.children) {
5819
+ for (const child of node.children) {
5820
+ const found = findNodeById2(child, id);
5821
+ if (found) return found;
5822
+ }
5823
+ }
5824
+ return null;
5825
+ }
5826
+
5827
+ // src/cli/commands/gotcha-survey.ts
5828
+ var GotchaSurveyOptionsSchema = z.object({
5829
+ preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
5476
5830
  token: z.string().optional(),
5477
- imageScale: z.string().optional(),
5478
- name: z.string().optional()
5831
+ config: z.string().optional(),
5832
+ targetNodeId: z.string().optional(),
5833
+ json: z.boolean().optional()
5834
+ });
5835
+ async function runGotchaSurvey(input, options) {
5836
+ const { file, nodeId } = await loadFile(input, options.token);
5837
+ const effectiveNodeId = options.targetNodeId ?? nodeId;
5838
+ let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
5839
+ if (options.config) {
5840
+ const configFile = await loadConfigFile(options.config);
5841
+ configs = mergeConfigs(configs, configFile);
5842
+ }
5843
+ const result = analyzeFile(file, {
5844
+ configs,
5845
+ ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
5846
+ });
5847
+ const scores = calculateScores(result, configs);
5848
+ return generateGotchaSurvey(result, scores);
5849
+ }
5850
+ function formatHumanSummary(survey) {
5851
+ const lines = [
5852
+ `Design grade: ${survey.designGrade}`,
5853
+ `Ready for code generation: ${survey.isReadyForCodeGen ? "yes" : "no"}`,
5854
+ `Questions: ${survey.questions.length}`
5855
+ ];
5856
+ if (survey.questions.length > 0) {
5857
+ lines.push("");
5858
+ lines.push("Use --json to get the full GotchaSurvey JSON for programmatic use.");
5859
+ }
5860
+ return lines.join("\n");
5861
+ }
5862
+ function registerGotchaSurvey(cli2) {
5863
+ cli2.command("gotcha-survey <input>", "Generate a gotcha survey from a Figma design analysis").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--json", "Output GotchaSurvey JSON to stdout (same format as MCP)").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
5864
+ const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
5865
+ if (!parseResult.success) {
5866
+ const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
5867
+ console.error(`
5868
+ Invalid options:
5869
+ ${msg}`);
5870
+ process.exit(1);
5871
+ }
5872
+ const options = parseResult.data;
5873
+ const analysisStart = Date.now();
5874
+ trackEvent(EVENTS.ANALYSIS_STARTED, {
5875
+ source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma",
5876
+ tool: "gotcha-survey"
5877
+ });
5878
+ const log = options.json ? console.error.bind(console) : console.log.bind(console);
5879
+ try {
5880
+ if (!options.token && !getFigmaToken() && !isJsonFile(input) && !isFixtureDir(input)) {
5881
+ throw new Error(
5882
+ "canicode is not configured. Run 'canicode init --token YOUR_TOKEN' first."
5883
+ );
5884
+ }
5885
+ const survey = await runGotchaSurvey(input, options);
5886
+ if (options.json) {
5887
+ console.log(JSON.stringify(survey, null, 2));
5888
+ } else {
5889
+ log(formatHumanSummary(survey));
5890
+ }
5891
+ trackEvent(EVENTS.ANALYSIS_COMPLETED, {
5892
+ grade: survey.designGrade,
5893
+ questionCount: survey.questions.length,
5894
+ isReadyForCodeGen: survey.isReadyForCodeGen,
5895
+ duration: Date.now() - analysisStart,
5896
+ tool: "gotcha-survey"
5897
+ });
5898
+ } catch (error) {
5899
+ trackError(
5900
+ error instanceof Error ? error : new Error(String(error)),
5901
+ { command: "gotcha-survey", input }
5902
+ );
5903
+ trackEvent(EVENTS.ANALYSIS_FAILED, {
5904
+ error: error instanceof Error ? error.message : String(error),
5905
+ duration: Date.now() - analysisStart,
5906
+ tool: "gotcha-survey"
5907
+ });
5908
+ console.error(
5909
+ "\nError:",
5910
+ error instanceof Error ? error.message : String(error)
5911
+ );
5912
+ process.exitCode = 1;
5913
+ }
5914
+ });
5915
+ }
5916
+ function registerDesignTree(cli2) {
5917
+ cli2.command(
5918
+ "design-tree <input>",
5919
+ "Generate a DOM-like design tree from a Figma file or fixture"
5920
+ ).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <path>", "Output file path (default: stdout)").option("--vector-dir <path>", "Directory with SVG files for VECTOR nodes (auto-detected from fixture path)").option("--image-dir <path>", "Directory with image PNGs for IMAGE fill nodes (auto-detected from fixture path)").example(" canicode design-tree ./fixtures/my-design").example(" canicode design-tree https://www.figma.com/design/ABC/File?node-id=1-234 --output tree.txt").action(async (input, options) => {
5921
+ try {
5922
+ const { file } = await loadFile(input, options.token);
5923
+ const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input);
5924
+ let vectorDir = options.vectorDir;
5925
+ if (!vectorDir) {
5926
+ const autoDir = resolve(fixtureBase, "vectors");
5927
+ if (existsSync(autoDir)) vectorDir = autoDir;
5928
+ }
5929
+ let imageDir = options.imageDir;
5930
+ if (!imageDir) {
5931
+ const autoDir = resolve(fixtureBase, "images");
5932
+ if (existsSync(autoDir)) imageDir = autoDir;
5933
+ }
5934
+ const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
5935
+ const treeOptions = {
5936
+ ...vectorDir ? { vectorDir } : {},
5937
+ ...imageDir ? { imageDir } : {}
5938
+ };
5939
+ const stats = generateDesignTreeWithStats2(file, treeOptions);
5940
+ if (options.output) {
5941
+ const outputDir = dirname(resolve(options.output));
5942
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
5943
+ const { writeFile: writeFileAsync } = await import('fs/promises');
5944
+ await writeFileAsync(resolve(options.output), stats.tree, "utf-8");
5945
+ console.log(`Design tree saved: ${resolve(options.output)} (${Math.round(stats.bytes / 1024)}KB, ~${stats.estimatedTokens} tokens)`);
5946
+ } else {
5947
+ console.log(stats.tree);
5948
+ }
5949
+ } catch (error) {
5950
+ console.error("\nError:", error instanceof Error ? error.message : String(error));
5951
+ process.exitCode = 1;
5952
+ }
5953
+ });
5954
+ }
5955
+ var positiveCliNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v > 0, "must be positive");
5956
+ var figmaScaleNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v >= 1, "must be >= 1");
5957
+ var VisualCompareCliOptionsSchema = z.object({
5958
+ figmaUrl: z.string().optional(),
5959
+ figmaScreenshot: z.string().optional(),
5960
+ token: z.string().optional(),
5961
+ output: z.string().optional(),
5962
+ width: positiveCliNumber.optional(),
5963
+ height: positiveCliNumber.optional(),
5964
+ figmaScale: figmaScaleNumber.optional(),
5965
+ expandRoot: z.boolean().optional()
5479
5966
  });
5480
- function registerSaveFixture(cli2) {
5967
+
5968
+ // src/cli/commands/visual-compare.ts
5969
+ function registerVisualCompare(cli2) {
5481
5970
  cli2.command(
5482
- "save-fixture <input>",
5483
- "Save Figma design as a fixture directory for offline analysis"
5484
- ).option("--output <path>", "Output directory (default: fixtures/<name>/)").option("--name <name>", "Fixture name (default: extracted from URL)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234").example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3").action(async (input, rawOptions) => {
5971
+ "visual-compare <codePath>",
5972
+ "Compare rendered code against Figma screenshot (pixel-level similarity)"
5973
+ ).option("--figma-url <url>", "Figma URL with node-id (required for API fetch)").option("--figma-screenshot <path>", "Local Figma screenshot file (skips API fetch)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory for screenshots and diff (default: /tmp/canicode-visual-compare)").option("--width <px>", "Logical viewport width in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--height <px>", "Logical viewport height in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--figma-scale <n>", "Figma export scale (default: 2, matches calibrate-save-fixture / @2x PNGs)").option("--expand-root", "Replace root element's fixed width with 100% before rendering (for responsive comparison)").example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'").action(async (codePath, rawOptions) => {
5485
5974
  try {
5486
- const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions);
5975
+ const parseResult = VisualCompareCliOptionsSchema.safeParse(rawOptions);
5487
5976
  if (!parseResult.success) {
5488
5977
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
5489
5978
  console.error(`
@@ -5492,453 +5981,20 @@ ${msg}`);
5492
5981
  process.exit(1);
5493
5982
  }
5494
5983
  const options = parseResult.data;
5495
- if (!isFigmaUrl(input)) {
5496
- throw new Error("save-fixture requires a Figma URL as input.");
5984
+ if (!options.figmaUrl && !options.figmaScreenshot) {
5985
+ console.error("Error: --figma-url or --figma-screenshot is required");
5986
+ process.exitCode = 1;
5987
+ return;
5497
5988
  }
5498
- if (options.imageScale !== void 0) {
5499
- const scale = Number(options.imageScale);
5500
- if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
5501
- console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
5502
- process.exit(1);
5503
- }
5989
+ if (options.figmaUrl && !parseFigmaUrl(options.figmaUrl).nodeId) {
5990
+ console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
5991
+ console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
5504
5992
  }
5505
- if (!parseFigmaUrl(input).nodeId) {
5506
- console.warn("\nWarning: No node-id specified. Saving entire file as fixture.");
5507
- console.warn("Tip: Add ?node-id=XXX to save a specific section.\n");
5508
- }
5509
- const { file } = await loadFile(input, options.token);
5510
- file.sourceUrl = input;
5511
- const fixtureName = options.name ?? file.fileKey;
5512
- const fixtureDir = resolve(options.output ?? `fixtures/${fixtureName}`);
5513
- mkdirSync(fixtureDir, { recursive: true });
5514
- const figmaTokenForComponents = options.token ?? getFigmaToken();
5515
- if (figmaTokenForComponents) {
5516
- const { FigmaClient: FC } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
5517
- const { resolveComponentDefinitions: resolveComponentDefinitions2, resolveInteractionDestinations: resolveInteractionDestinations2 } = await Promise.resolve().then(() => (init_component_resolver(), component_resolver_exports));
5518
- const componentClient = new FC({ token: figmaTokenForComponents });
5519
- try {
5520
- const definitions = await resolveComponentDefinitions2(componentClient, file.fileKey, file.document);
5521
- const count = Object.keys(definitions).length;
5522
- if (count > 0) {
5523
- file.componentDefinitions = definitions;
5524
- console.log(`Resolved ${count} component master node tree(s)`);
5525
- }
5526
- const interactionDests = await resolveInteractionDestinations2(componentClient, file.fileKey, file.document, file.componentDefinitions);
5527
- const destCount = Object.keys(interactionDests).length;
5528
- if (destCount > 0) {
5529
- file.interactionDestinations = interactionDests;
5530
- console.log(`Resolved ${destCount} interaction destination(s)`);
5531
- }
5532
- } catch {
5533
- console.warn("Warning: failed to resolve component definitions (continuing)");
5534
- }
5535
- }
5536
- const dataPath = resolve(fixtureDir, "data.json");
5537
- await writeFile(dataPath, JSON.stringify(file, null, 2), "utf-8");
5538
- console.log(`Fixture saved: ${fixtureDir}/`);
5539
- console.log(` data.json: ${file.name} (${countNodes2(file.document)} nodes)`);
5540
- const figmaToken = options.token ?? getFigmaToken();
5541
- if (figmaToken) {
5542
- const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
5543
- const client = new FigmaClient2({ token: figmaToken });
5544
- const { nodeId } = parseFigmaUrl(input);
5545
- const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
5546
- try {
5547
- const imageUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
5548
- const url = imageUrls[rootNodeId];
5549
- if (url) {
5550
- const resp = await fetch(url);
5551
- if (resp.ok) {
5552
- const buffer = Buffer.from(await resp.arrayBuffer());
5553
- const { writeFile: writeFileSync6 } = await import('fs/promises');
5554
- await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
5555
- console.log(` screenshot.png: saved`);
5556
- }
5557
- }
5558
- } catch {
5559
- console.warn(" screenshot.png: failed to download (continuing)");
5560
- }
5561
- const vectorNodes = collectVectorNodes(file.document);
5562
- if (vectorNodes.length > 0) {
5563
- const vectorDir = resolve(fixtureDir, "vectors");
5564
- mkdirSync(vectorDir, { recursive: true });
5565
- const svgUrls = await client.getNodeImages(
5566
- file.fileKey,
5567
- vectorNodes.map((n) => n.id),
5568
- { format: "svg" }
5569
- );
5570
- const mapping = {};
5571
- const usedNames = /* @__PURE__ */ new Map();
5572
- let downloaded = 0;
5573
- for (const { id, name } of vectorNodes) {
5574
- let base = sanitizeFilename(name);
5575
- const count = usedNames.get(base) ?? 0;
5576
- usedNames.set(base, count + 1);
5577
- if (count > 0) base = `${base}-${count + 1}`;
5578
- const filename = `${base}.svg`;
5579
- mapping[id] = filename;
5580
- const svgUrl = svgUrls[id];
5581
- if (!svgUrl) continue;
5582
- try {
5583
- const resp = await fetch(svgUrl);
5584
- if (resp.ok) {
5585
- const svg = await resp.text();
5586
- await writeFile(resolve(vectorDir, filename), svg, "utf-8");
5587
- downloaded++;
5588
- }
5589
- } catch {
5590
- }
5591
- }
5592
- await writeFile(resolve(vectorDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
5593
- console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
5594
- }
5595
- const imageNodes = collectImageNodes(file.document);
5596
- if (imageNodes.length > 0) {
5597
- const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
5598
- const imageDir = resolve(fixtureDir, "images");
5599
- mkdirSync(imageDir, { recursive: true });
5600
- const imageFills = await client.getImageFills(file.fileKey);
5601
- const usedNames = /* @__PURE__ */ new Map();
5602
- const mapping = {};
5603
- let imgDownloaded = 0;
5604
- for (const { id, name, imageRef } of imageNodes) {
5605
- let base = sanitizeFilename(name);
5606
- const count = usedNames.get(base) ?? 0;
5607
- usedNames.set(base, count + 1);
5608
- if (count > 0) base = `${base}-${count + 1}`;
5609
- const filename = `${base}@${imgScale}x.png`;
5610
- mapping[id] = filename;
5611
- if (!imageRef) continue;
5612
- const imgUrl = imageFills[imageRef];
5613
- if (!imgUrl) continue;
5614
- try {
5615
- const resp = await fetch(imgUrl);
5616
- if (resp.ok) {
5617
- const buf = Buffer.from(await resp.arrayBuffer());
5618
- await writeFile(resolve(imageDir, filename), buf);
5619
- imgDownloaded++;
5620
- }
5621
- } catch {
5622
- }
5623
- }
5624
- await writeFile(
5625
- resolve(imageDir, "mapping.json"),
5626
- JSON.stringify(mapping, null, 2),
5627
- "utf-8"
5628
- );
5629
- console.log(` images/: ${imgDownloaded}/${imageNodes.length} PNGs (@${imgScale}x)`);
5630
- }
5631
- }
5632
- } catch (error) {
5633
- console.error(
5634
- "\nError:",
5635
- error instanceof Error ? error.message : String(error)
5636
- );
5637
- process.exitCode = 1;
5638
- }
5639
- });
5640
- }
5641
- function registerDesignTree(cli2) {
5642
- cli2.command(
5643
- "design-tree <input>",
5644
- "Generate a DOM-like design tree from a Figma file or fixture"
5645
- ).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <path>", "Output file path (default: stdout)").option("--vector-dir <path>", "Directory with SVG files for VECTOR nodes (auto-detected from fixture path)").option("--image-dir <path>", "Directory with image PNGs for IMAGE fill nodes (auto-detected from fixture path)").example(" canicode design-tree ./fixtures/my-design").example(" canicode design-tree https://www.figma.com/design/ABC/File?node-id=1-234 --output tree.txt").action(async (input, options) => {
5646
- try {
5647
- const { file } = await loadFile(input, options.token);
5648
- const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input);
5649
- let vectorDir = options.vectorDir;
5650
- if (!vectorDir) {
5651
- const autoDir = resolve(fixtureBase, "vectors");
5652
- if (existsSync(autoDir)) vectorDir = autoDir;
5653
- }
5654
- let imageDir = options.imageDir;
5655
- if (!imageDir) {
5656
- const autoDir = resolve(fixtureBase, "images");
5657
- if (existsSync(autoDir)) imageDir = autoDir;
5658
- }
5659
- const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
5660
- const treeOptions = {
5661
- ...vectorDir ? { vectorDir } : {},
5662
- ...imageDir ? { imageDir } : {}
5663
- };
5664
- const stats = generateDesignTreeWithStats2(file, treeOptions);
5665
- if (options.output) {
5666
- const outputDir = dirname(resolve(options.output));
5667
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
5668
- const { writeFile: writeFileAsync } = await import('fs/promises');
5669
- await writeFileAsync(resolve(options.output), stats.tree, "utf-8");
5670
- console.log(`Design tree saved: ${resolve(options.output)} (${Math.round(stats.bytes / 1024)}KB, ~${stats.estimatedTokens} tokens)`);
5671
- } else {
5672
- console.log(stats.tree);
5673
- }
5674
- } catch (error) {
5675
- console.error("\nError:", error instanceof Error ? error.message : String(error));
5676
- process.exitCode = 1;
5677
- }
5678
- });
5679
- }
5680
- var ImplementOptionsSchema = z.object({
5681
- token: z.string().optional(),
5682
- output: z.string().optional(),
5683
- prompt: z.string().optional(),
5684
- imageScale: z.string().optional()
5685
- });
5686
- function registerImplement(cli2) {
5687
- cli2.command(
5688
- "implement <input>",
5689
- "Prepare design-to-code package: analysis + design tree + assets + prompt"
5690
- ).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory (default: ./canicode-implement/)").option("--prompt <path>", "Custom prompt file (default: built-in HTML+CSS prompt)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode implement ./fixtures/my-design").example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3").action(async (input, rawOptions) => {
5691
- try {
5692
- const parseResult = ImplementOptionsSchema.safeParse(rawOptions);
5693
- if (!parseResult.success) {
5694
- const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
5695
- console.error(`
5696
- Invalid options:
5697
- ${msg}`);
5698
- process.exit(1);
5699
- }
5700
- const options = parseResult.data;
5701
- if (options.imageScale !== void 0) {
5702
- const scale = Number(options.imageScale);
5703
- if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
5704
- console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
5705
- process.exit(1);
5706
- }
5707
- }
5708
- if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
5709
- console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file.");
5710
- console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
5711
- }
5712
- const outputDir = resolve(options.output ?? "canicode-implement");
5713
- mkdirSync(outputDir, { recursive: true });
5714
- console.log("\nPreparing implementation package...\n");
5715
- const { file } = await loadFile(input, options.token);
5716
- console.log(`Design: ${file.name}`);
5717
- const result = analyzeFile(file);
5718
- const scores = calculateScores(result);
5719
- const resultJson = buildResultJson(file.name, result, scores, { fileKey: file.fileKey });
5720
- await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8");
5721
- console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`);
5722
- const fixtureBase = isJsonFile(input) || isFixtureDir(input) ? isJsonFile(input) ? dirname(resolve(input)) : resolve(input) : void 0;
5723
- let vectorDir = fixtureBase ? resolve(fixtureBase, "vectors") : void 0;
5724
- let imageDir = fixtureBase ? resolve(fixtureBase, "images") : void 0;
5725
- if (vectorDir && existsSync(vectorDir)) {
5726
- const vecOutputDir = resolve(outputDir, "vectors");
5727
- mkdirSync(vecOutputDir, { recursive: true });
5728
- const { readdirSync: readdirSync3, copyFileSync: copyFileSync2 } = await import('fs');
5729
- const vecFiles = readdirSync3(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
5730
- for (const f of vecFiles) {
5731
- copyFileSync2(resolve(vectorDir, f), resolve(vecOutputDir, f));
5732
- }
5733
- vectorDir = vecOutputDir;
5734
- console.log(` vectors/: ${vecFiles.length} SVGs copied`);
5735
- }
5736
- if (imageDir && existsSync(imageDir)) {
5737
- const imgOutputDir = resolve(outputDir, "images");
5738
- mkdirSync(imgOutputDir, { recursive: true });
5739
- const { readdirSync: readdirSync3, copyFileSync: copyFileSync2 } = await import('fs');
5740
- const imgFiles = readdirSync3(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
5741
- for (const f of imgFiles) {
5742
- copyFileSync2(resolve(imageDir, f), resolve(imgOutputDir, f));
5743
- }
5744
- imageDir = imgOutputDir;
5745
- const pngCount = imgFiles.filter((f) => f.endsWith(".png")).length;
5746
- console.log(` images/: ${pngCount} assets copied`);
5747
- }
5748
- if (isFigmaUrl(input) && !fixtureBase) {
5749
- const figmaToken = options.token ?? getFigmaToken();
5750
- if (figmaToken) {
5751
- const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
5752
- const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
5753
- const client = new FigmaClient2({ token: figmaToken });
5754
- const { nodeId } = parseFigmaUrl(input);
5755
- const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
5756
- try {
5757
- const screenshotUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
5758
- const screenshotUrl = screenshotUrls[rootNodeId];
5759
- if (screenshotUrl) {
5760
- const resp = await fetch(screenshotUrl);
5761
- if (resp.ok) {
5762
- const buf = Buffer.from(await resp.arrayBuffer());
5763
- await writeFile(resolve(outputDir, "screenshot.png"), buf);
5764
- console.log(` screenshot.png: saved`);
5765
- }
5766
- }
5767
- } catch {
5768
- console.warn(" screenshot.png: failed to download (continuing)");
5769
- }
5770
- const vectorNodes = collectVectorNodes(file.document);
5771
- if (vectorNodes.length > 0) {
5772
- const vecOutDir = resolve(outputDir, "vectors");
5773
- mkdirSync(vecOutDir, { recursive: true });
5774
- try {
5775
- const svgUrls = await client.getNodeImages(
5776
- file.fileKey,
5777
- vectorNodes.map((n) => n.id),
5778
- { format: "svg" }
5779
- );
5780
- const mapping = {};
5781
- const usedNames = /* @__PURE__ */ new Map();
5782
- let downloaded = 0;
5783
- for (const { id, name } of vectorNodes) {
5784
- let base = sanitizeFilename(name);
5785
- const count = usedNames.get(base) ?? 0;
5786
- usedNames.set(base, count + 1);
5787
- if (count > 0) base = `${base}-${count + 1}`;
5788
- const filename = `${base}.svg`;
5789
- mapping[id] = filename;
5790
- const svgUrl = svgUrls[id];
5791
- if (!svgUrl) continue;
5792
- try {
5793
- const resp = await fetch(svgUrl);
5794
- if (resp.ok) {
5795
- const svg = await resp.text();
5796
- await writeFile(resolve(vecOutDir, filename), svg, "utf-8");
5797
- downloaded++;
5798
- }
5799
- } catch {
5800
- }
5801
- }
5802
- await writeFile(resolve(vecOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
5803
- console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
5804
- } catch {
5805
- console.warn(" vectors/: failed to download (continuing)");
5806
- }
5807
- }
5808
- const imgNodes = collectImageNodes(file.document);
5809
- if (imgNodes.length > 0) {
5810
- const imgOutDir = resolve(outputDir, "images");
5811
- mkdirSync(imgOutDir, { recursive: true });
5812
- try {
5813
- const imageFills = await client.getImageFills(file.fileKey);
5814
- const mapping = {};
5815
- const usedNames = /* @__PURE__ */ new Map();
5816
- let downloaded = 0;
5817
- for (const { id, name, imageRef } of imgNodes) {
5818
- let base = sanitizeFilename(name);
5819
- const count = usedNames.get(base) ?? 0;
5820
- usedNames.set(base, count + 1);
5821
- if (count > 0) base = `${base}-${count + 1}`;
5822
- const filename = `${base}@${imgScale}x.png`;
5823
- mapping[id] = filename;
5824
- if (!imageRef) continue;
5825
- const imgUrl = imageFills[imageRef];
5826
- if (!imgUrl) continue;
5827
- try {
5828
- const resp = await fetch(imgUrl);
5829
- if (resp.ok) {
5830
- const buf = Buffer.from(await resp.arrayBuffer());
5831
- await writeFile(resolve(imgOutDir, filename), buf);
5832
- downloaded++;
5833
- }
5834
- } catch {
5835
- }
5836
- }
5837
- await writeFile(resolve(imgOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
5838
- imageDir = imgOutDir;
5839
- console.log(` images/: ${downloaded}/${imgNodes.length} PNGs (@${imgScale}x)`);
5840
- } catch {
5841
- console.warn(" images/: failed to download (continuing)");
5842
- }
5843
- }
5844
- const vecOutCheck = resolve(outputDir, "vectors");
5845
- if (existsSync(vecOutCheck)) vectorDir = vecOutCheck;
5846
- }
5847
- }
5848
- const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
5849
- const treeOptions = {
5850
- ...vectorDir && existsSync(vectorDir) ? { vectorDir } : {},
5851
- ...imageDir && existsSync(imageDir) ? { imageDir } : {}
5852
- };
5853
- const stats = generateDesignTreeWithStats2(file, treeOptions);
5854
- await writeFile(resolve(outputDir, "design-tree.txt"), stats.tree, "utf-8");
5855
- console.log(` design-tree.txt: ~${stats.estimatedTokens} tokens`);
5856
- if (options.prompt) {
5857
- const { readFile: rf } = await import('fs/promises');
5858
- const customPrompt = await rf(resolve(options.prompt), "utf-8");
5859
- await writeFile(resolve(outputDir, "PROMPT.md"), customPrompt, "utf-8");
5860
- console.log(` PROMPT.md: custom (${options.prompt})`);
5861
- } else {
5862
- const { readFile: rf } = await import('fs/promises');
5863
- const { dirname: dirnameFn, resolve: resolveFn } = await import('path');
5864
- const { fileURLToPath } = await import('url');
5865
- const cliDir = dirnameFn(fileURLToPath(import.meta.url));
5866
- const projectRoot = resolveFn(cliDir, "../..");
5867
- const altRoot = resolveFn(cliDir, "..");
5868
- let prompt = "";
5869
- for (const root of [projectRoot, altRoot]) {
5870
- const p = resolveFn(root, ".claude/skills/design-to-code/PROMPT.md");
5871
- try {
5872
- prompt = await rf(p, "utf-8");
5873
- break;
5874
- } catch {
5875
- }
5876
- }
5877
- if (prompt) {
5878
- await writeFile(resolve(outputDir, "PROMPT.md"), prompt, "utf-8");
5879
- console.log(` PROMPT.md: default (html-css)`);
5880
- } else {
5881
- console.warn(" PROMPT.md: built-in prompt not found (skipped)");
5882
- }
5883
- }
5884
- console.log(`
5885
- ${"=".repeat(50)}`);
5886
- console.log(`Implementation package ready: ${outputDir}/`);
5887
- console.log(` Grade: ${scores.overall.grade} (${scores.overall.percentage}%)`);
5888
- console.log(` Issues: ${result.issues.length}`);
5889
- console.log(` Design tree: ~${stats.estimatedTokens} tokens`);
5890
- console.log(`${"=".repeat(50)}`);
5891
- console.log(`
5892
- Next: Feed design-tree.txt + PROMPT.md to your AI assistant.`);
5893
- } catch (error) {
5894
- console.error("\nError:", error instanceof Error ? error.message : String(error));
5895
- process.exitCode = 1;
5896
- }
5897
- });
5898
- }
5899
- var positiveCliNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v > 0, "must be positive");
5900
- var figmaScaleNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v >= 1, "must be >= 1");
5901
- var VisualCompareCliOptionsSchema = z.object({
5902
- figmaUrl: z.string().optional(),
5903
- figmaScreenshot: z.string().optional(),
5904
- token: z.string().optional(),
5905
- output: z.string().optional(),
5906
- width: positiveCliNumber.optional(),
5907
- height: positiveCliNumber.optional(),
5908
- figmaScale: figmaScaleNumber.optional(),
5909
- expandRoot: z.boolean().optional()
5910
- });
5911
-
5912
- // src/cli/commands/visual-compare.ts
5913
- function registerVisualCompare(cli2) {
5914
- cli2.command(
5915
- "visual-compare <codePath>",
5916
- "Compare rendered code against Figma screenshot (pixel-level similarity)"
5917
- ).option("--figma-url <url>", "Figma URL with node-id (required for API fetch)").option("--figma-screenshot <path>", "Local Figma screenshot file (skips API fetch)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory for screenshots and diff (default: /tmp/canicode-visual-compare)").option("--width <px>", "Logical viewport width in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--height <px>", "Logical viewport height in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--figma-scale <n>", "Figma export scale (default: 2, matches save-fixture / @2x PNGs)").option("--expand-root", "Replace root element's fixed width with 100% before rendering (for responsive comparison)").example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'").action(async (codePath, rawOptions) => {
5918
- try {
5919
- const parseResult = VisualCompareCliOptionsSchema.safeParse(rawOptions);
5920
- if (!parseResult.success) {
5921
- const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
5922
- console.error(`
5923
- Invalid options:
5924
- ${msg}`);
5925
- process.exit(1);
5926
- }
5927
- const options = parseResult.data;
5928
- if (!options.figmaUrl && !options.figmaScreenshot) {
5929
- console.error("Error: --figma-url or --figma-screenshot is required");
5930
- process.exitCode = 1;
5931
- return;
5932
- }
5933
- if (options.figmaUrl && !parseFigmaUrl(options.figmaUrl).nodeId) {
5934
- console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
5935
- console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
5936
- }
5937
- const token = options.token ?? getFigmaToken();
5938
- if (!token && !options.figmaScreenshot) {
5939
- console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var (or use --figma-screenshot for local files).");
5940
- process.exitCode = 1;
5941
- return;
5993
+ const token = options.token ?? getFigmaToken();
5994
+ if (!token && !options.figmaScreenshot) {
5995
+ console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var (or use --figma-screenshot for local files).");
5996
+ process.exitCode = 1;
5997
+ return;
5942
5998
  }
5943
5999
  const { visualCompare: visualCompare2 } = await Promise.resolve().then(() => (init_visual_compare(), visual_compare_exports));
5944
6000
  const hasViewportOverride = options.width !== void 0 || options.height !== void 0;
@@ -5975,13 +6031,150 @@ ${msg}`);
5975
6031
  );
5976
6032
  process.exitCode = 1;
5977
6033
  }
5978
- });
6034
+ });
6035
+ }
6036
+ var InstallSkillsOptionsSchema = z.object({
6037
+ target: z.enum(["project", "global"]),
6038
+ force: z.boolean(),
6039
+ cwd: z.string().optional(),
6040
+ sourceDir: z.string().optional()
6041
+ });
6042
+ var SKILL_NAMES = ["canicode", "canicode-gotchas", "canicode-roundtrip"];
6043
+ function defaultSourceDir() {
6044
+ return fileURLToPath(new URL("../../skills/", import.meta.url));
6045
+ }
6046
+ async function installSkills(rawOptions) {
6047
+ const options = InstallSkillsOptionsSchema.parse(rawOptions);
6048
+ const sourceDir = options.sourceDir ?? defaultSourceDir();
6049
+ if (!existsSync(sourceDir)) {
6050
+ throw new Error(
6051
+ `Bundled skills directory not found: ${sourceDir}
6052
+ If you are developing canicode, run 'pnpm build' first.
6053
+ If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
6054
+ );
6055
+ }
6056
+ const cwd = options.cwd ?? process.cwd();
6057
+ const targetDir = options.target === "global" ? join(homedir(), ".claude", "skills") : join(cwd, ".claude", "skills");
6058
+ mkdirSync(targetDir, { recursive: true });
6059
+ const summary = {
6060
+ installed: [],
6061
+ overwritten: [],
6062
+ skipped: [],
6063
+ targetDir
6064
+ };
6065
+ const ops = [];
6066
+ for (const skillName of SKILL_NAMES) {
6067
+ const srcSkillDir = join(sourceDir, skillName);
6068
+ if (!existsSync(srcSkillDir)) {
6069
+ throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
6070
+ }
6071
+ const destSkillDir = join(targetDir, skillName);
6072
+ mkdirSync(destSkillDir, { recursive: true });
6073
+ const files = listFilesRecursive(srcSkillDir);
6074
+ for (const relPath of files) {
6075
+ const src = join(srcSkillDir, relPath);
6076
+ const dest = join(destSkillDir, relPath);
6077
+ mkdirSync(dirname(dest), { recursive: true });
6078
+ const label = join(skillName, relPath);
6079
+ let action;
6080
+ if (!existsSync(dest)) {
6081
+ action = "install";
6082
+ } else if (options.force) {
6083
+ action = "force-overwrite";
6084
+ } else {
6085
+ action = "needs-decision";
6086
+ }
6087
+ ops.push({ src, dest, label, action });
6088
+ }
6089
+ }
6090
+ const candidates = ops.filter((op) => op.action === "needs-decision");
6091
+ const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
6092
+ for (const op of ops) {
6093
+ if (op.action === "install") {
6094
+ copyFileSync(op.src, op.dest);
6095
+ summary.installed.push(op.label);
6096
+ } else if (op.action === "force-overwrite") {
6097
+ copyFileSync(op.src, op.dest);
6098
+ summary.overwritten.push(op.label);
6099
+ } else {
6100
+ const decision = decisions.get(op.label) ?? "skip";
6101
+ if (decision === "overwrite") {
6102
+ copyFileSync(op.src, op.dest);
6103
+ summary.overwritten.push(op.label);
6104
+ } else {
6105
+ summary.skipped.push(op.label);
6106
+ }
6107
+ }
6108
+ }
6109
+ return summary;
6110
+ }
6111
+ function listFilesRecursive(dir) {
6112
+ const out = [];
6113
+ const walk = (current) => {
6114
+ for (const entry of readdirSync(current)) {
6115
+ const full = join(current, entry);
6116
+ const stat = statSync(full);
6117
+ if (stat.isDirectory()) {
6118
+ walk(full);
6119
+ } else if (stat.isFile()) {
6120
+ out.push(relative(dir, full));
6121
+ }
6122
+ }
6123
+ };
6124
+ walk(dir);
6125
+ return out;
6126
+ }
6127
+ async function promptOverwriteBatch(candidates) {
6128
+ const decisions = /* @__PURE__ */ new Map();
6129
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
6130
+ for (const { label } of candidates) {
6131
+ decisions.set(label, "skip");
6132
+ }
6133
+ return decisions;
6134
+ }
6135
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
6136
+ try {
6137
+ let mode = "ask";
6138
+ for (const { label, dest } of candidates) {
6139
+ if (mode === "all") {
6140
+ decisions.set(label, "overwrite");
6141
+ continue;
6142
+ }
6143
+ if (mode === "none") {
6144
+ decisions.set(label, "skip");
6145
+ continue;
6146
+ }
6147
+ const answer = (await rl.question(
6148
+ `File exists: ${dest}. Overwrite? [y/N/a=all/s=skip-all] `
6149
+ )).trim().toLowerCase();
6150
+ if (answer === "a") {
6151
+ decisions.set(label, "overwrite");
6152
+ mode = "all";
6153
+ } else if (answer === "s") {
6154
+ decisions.set(label, "skip");
6155
+ mode = "none";
6156
+ } else if (answer.startsWith("y")) {
6157
+ decisions.set(label, "overwrite");
6158
+ } else {
6159
+ decisions.set(label, "skip");
6160
+ }
6161
+ }
6162
+ } finally {
6163
+ rl.close();
6164
+ }
6165
+ return decisions;
5979
6166
  }
6167
+
6168
+ // src/cli/commands/init.ts
5980
6169
  var InitOptionsSchema = z.object({
5981
- token: z.string().optional()
6170
+ token: z.string().optional(),
6171
+ global: z.boolean().optional(),
6172
+ // cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
6173
+ skills: z.boolean().optional(),
6174
+ force: z.boolean().optional()
5982
6175
  });
5983
6176
  function registerInit(cli2) {
5984
- cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token to ~/.canicode/").action((rawOptions) => {
6177
+ cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token and install Claude Code skills to .claude/skills/").option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--no-skills", "Skip skill installation (token only)").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
5985
6178
  try {
5986
6179
  const parseResult = InitOptionsSchema.safeParse(rawOptions);
5987
6180
  if (!parseResult.success) {
@@ -5996,14 +6189,60 @@ ${msg}`);
5996
6189
  initAiready(options.token);
5997
6190
  console.log(` Config saved: ${getConfigPath()}`);
5998
6191
  console.log(` Reports will be saved to: ${getReportsDir()}/`);
5999
- console.log(`
6192
+ let skillStepOk = true;
6193
+ let skillSummary;
6194
+ if (options.skills !== false) {
6195
+ try {
6196
+ const summary = await installSkills({
6197
+ target: options.global ? "global" : "project",
6198
+ force: options.force ?? false
6199
+ });
6200
+ console.log(`
6201
+ Skills installed to: ${summary.targetDir}/`);
6202
+ console.log(` installed: ${summary.installed.length}`);
6203
+ console.log(` overwritten: ${summary.overwritten.length}`);
6204
+ console.log(` skipped: ${summary.skipped.length}`);
6205
+ if (summary.skipped.length > 0) {
6206
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
6207
+ }
6208
+ skillSummary = {
6209
+ installed: summary.installed.length,
6210
+ overwritten: summary.overwritten.length,
6211
+ skipped: summary.skipped.length
6212
+ };
6213
+ } catch (skillError) {
6214
+ console.error(
6215
+ `
6216
+ Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
6217
+ );
6218
+ process.exitCode = 1;
6219
+ skillStepOk = false;
6220
+ }
6221
+ }
6222
+ trackEvent(EVENTS.CLI_INIT, {
6223
+ skillsRequested: options.skills !== false,
6224
+ skillStepOk,
6225
+ target: options.global ? "global" : "project",
6226
+ force: options.force ?? false,
6227
+ ...skillSummary ?? {}
6228
+ });
6229
+ if (skillStepOk) {
6230
+ console.log(`
6000
6231
  Next: canicode analyze "https://www.figma.com/design/..."`);
6232
+ }
6001
6233
  return;
6002
6234
  }
6003
6235
  console.log(`CANICODE SETUP
6004
6236
  `);
6005
6237
  console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
6006
6238
  console.log(` Get token: figma.com > Settings > Personal access tokens
6239
+ `);
6240
+ console.log(`Skills:`);
6241
+ console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
6242
+ console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
6243
+ console.log(` --global Install to ~/.claude/skills/ instead`);
6244
+ console.log(` --no-skills Skip skill install (token only)`);
6245
+ console.log(` --force Overwrite existing skill files without prompting
6007
6246
  `);
6008
6247
  console.log(`After setup:`);
6009
6248
  console.log(` canicode analyze "https://www.figma.com/design/..."`);
@@ -6102,35 +6341,24 @@ function registerListRules(cli2) {
6102
6341
  });
6103
6342
  }
6104
6343
 
6105
- // src/cli/commands/prompt.ts
6106
- function registerPrompt(cli2) {
6107
- cli2.command("prompt", "Output the standard design-to-code prompt for AI code generation").action(async () => {
6108
- try {
6109
- const { readFile: readFile3 } = await import('fs/promises');
6110
- const { dirname: dirnameFn, resolve: resolveFn } = await import('path');
6111
- const { fileURLToPath } = await import('url');
6112
- const __dirname = dirnameFn(fileURLToPath(import.meta.url));
6113
- const paths = [
6114
- resolveFn(__dirname, "../../.claude/skills/design-to-code/PROMPT.md"),
6115
- resolveFn(__dirname, "../.claude/skills/design-to-code/PROMPT.md")
6116
- ];
6117
- for (const p of paths) {
6118
- try {
6119
- const content = await readFile3(p, "utf-8");
6120
- console.log(content);
6121
- return;
6122
- } catch {
6123
- }
6124
- }
6125
- console.error("Prompt file not found");
6126
- process.exitCode = 1;
6127
- return;
6128
- } catch (error) {
6129
- console.error("Error:", error instanceof Error ? error.message : String(error));
6130
- process.exitCode = 1;
6131
- }
6132
- });
6133
- }
6344
+ // src/cli/internal-commands.ts
6345
+ var INTERNAL_COMMANDS = [
6346
+ "calibrate-analyze",
6347
+ "calibrate-evaluate",
6348
+ "calibrate-implement",
6349
+ "calibrate-gap-report",
6350
+ "calibrate-run",
6351
+ "calibrate-gather-evidence",
6352
+ "calibrate-finalize-debate",
6353
+ "calibrate-enrich-evidence",
6354
+ "calibrate-prune-evidence",
6355
+ "calibrate-save-fixture",
6356
+ "fixture-list",
6357
+ "fixture-done",
6358
+ "design-tree-strip",
6359
+ "html-postprocess",
6360
+ "code-metrics"
6361
+ ];
6134
6362
  var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
6135
6363
  var RuleRelatedStruggleSchema = z.object({
6136
6364
  ruleId: z.string(),
@@ -7092,7 +7320,7 @@ function enrichCalibrationEvidence(reviews, fixture, evidencePath = DEFAULT_CALI
7092
7320
  writeJsonArray(evidencePath, enriched);
7093
7321
  }
7094
7322
 
7095
- // src/agents/orchestrator.ts
7323
+ // src/agents/calibration-compute.ts
7096
7324
  function normalizeActualImpact(impact) {
7097
7325
  const mapping = {
7098
7326
  none: "easy",
@@ -7943,7 +8171,7 @@ function registerCalibrateGapReport(cli2) {
7943
8171
  function registerCalibrateRun(cli2) {
7944
8172
  cli2.command(
7945
8173
  "calibrate-run <input>",
7946
- "Run full calibration pipeline (analysis-only, conversion via /calibrate-loop)"
8174
+ "Run full calibration pipeline (analysis-only, conversion via /calibrate)"
7947
8175
  ).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--max-nodes <count>", "Max nodes to convert", { default: 5 }).option("--sampling <strategy>", "Sampling strategy (all | top-issues | random)", { default: "top-issues" }).action(async (input, options) => {
7948
8176
  try {
7949
8177
  const figmaToken = options.token ?? getFigmaToken();
@@ -7963,7 +8191,7 @@ function registerCalibrateRun(cli2) {
7963
8191
  console.log("\nCalibration complete (analysis-only).");
7964
8192
  console.log(` Grade: ${analysisOutput.scoreReport.overall.grade} (${analysisOutput.scoreReport.overall.percentage}%)`);
7965
8193
  console.log(` Nodes with issues: ${analysisOutput.nodeIssueSummaries.length}`);
7966
- console.log(" Note: Use /calibrate-loop in Claude Code for full pipeline with visual comparison.");
8194
+ console.log(" Note: Use /calibrate in Claude Code for full pipeline with visual comparison.");
7967
8195
  } catch (error) {
7968
8196
  console.error(
7969
8197
  "\nError:",
@@ -8726,66 +8954,449 @@ function registerDesignTreeStrip(cli2) {
8726
8954
  await writeFile(outputPath, stripped, "utf-8");
8727
8955
  console.log(` ${type}.txt (${Math.round(Buffer.byteLength(stripped) / 1024)}KB)`);
8728
8956
  }
8729
- console.log(`Stripped ${types.length} design-tree variants \u2192 ${outputDir}`);
8957
+ console.log(`Stripped ${types.length} design-tree variants \u2192 ${outputDir}`);
8958
+ } catch (error) {
8959
+ console.error("\nError:", error instanceof Error ? error.message : String(error));
8960
+ process.exitCode = 1;
8961
+ }
8962
+ });
8963
+ }
8964
+ function sanitizeHtml(html) {
8965
+ let result = html;
8966
+ result = result.replace(/^\/\/\s*filename:.*\n/i, "");
8967
+ result = result.replace(/<script[\s\S]*?<\/script>/gi, "");
8968
+ result = result.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
8969
+ result = result.replace(
8970
+ /\s+(href|src|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi,
8971
+ (_, attr) => ` ${attr}="#"`
8972
+ );
8973
+ return result;
8974
+ }
8975
+ function injectLocalFont(html) {
8976
+ const fontPath = resolve("assets/fonts/Inter.var.woff2");
8977
+ if (!existsSync(fontPath)) return html;
8978
+ const fontUrl = pathToFileURL(fontPath).href;
8979
+ const fontCss = `@font-face { font-family: "Inter"; src: url("${fontUrl}") format("woff2"); font-weight: 100 900; }`;
8980
+ let result = html;
8981
+ result = result.replace(/<link[^>]*fonts\.googleapis\.com[^>]*>/gi, "");
8982
+ result = result.replace(/<link[^>]*fonts\.gstatic\.com[^>]*>/gi, "");
8983
+ if (result.includes("<style>")) {
8984
+ result = result.replace("<style>", `<style>
8985
+ ${fontCss}
8986
+ `);
8987
+ } else if (result.includes("</head>")) {
8988
+ result = result.replace("</head>", `<style>${fontCss}</style>
8989
+ </head>`);
8990
+ }
8991
+ return result;
8992
+ }
8993
+
8994
+ // src/cli/commands/internal/html-postprocess.ts
8995
+ function registerHtmlPostprocess(cli2) {
8996
+ cli2.command(
8997
+ "html-postprocess <input>",
8998
+ "[internal] Sanitize HTML and inject local fonts"
8999
+ ).option("--output <path>", "Output path (default: overwrite input)").action(async (input, options) => {
9000
+ try {
9001
+ const inputPath = resolve(input);
9002
+ if (!existsSync(inputPath)) {
9003
+ console.error(`Error: Input file not found: ${inputPath}`);
9004
+ process.exitCode = 1;
9005
+ return;
9006
+ }
9007
+ const raw = readFileSync(inputPath, "utf-8");
9008
+ const html = injectLocalFont(sanitizeHtml(raw));
9009
+ const outputPath = options.output ? resolve(options.output) : inputPath;
9010
+ await writeFile(outputPath, html, "utf-8");
9011
+ console.log(JSON.stringify({
9012
+ inputPath,
9013
+ outputPath,
9014
+ inputBytes: Buffer.byteLength(raw, "utf-8"),
9015
+ outputBytes: Buffer.byteLength(html, "utf-8")
9016
+ }));
9017
+ } catch (error) {
9018
+ console.error("\nError:", error instanceof Error ? error.message : String(error));
9019
+ process.exitCode = 1;
9020
+ }
9021
+ });
9022
+ }
9023
+ var SaveFixtureOptionsSchema = z.object({
9024
+ output: z.string().optional(),
9025
+ api: z.boolean().optional(),
9026
+ token: z.string().optional(),
9027
+ imageScale: z.string().optional(),
9028
+ name: z.string().optional()
9029
+ });
9030
+ function registerCalibrateSaveFixture(cli2) {
9031
+ cli2.command(
9032
+ "calibrate-save-fixture <input>",
9033
+ "Save Figma design as a fixture directory for calibration"
9034
+ ).option("--output <path>", "Output directory (default: fixtures/<name>/)").option("--name <name>", "Fixture name (default: extracted from URL)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode calibrate-save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234").example(" canicode calibrate-save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3").action(async (input, rawOptions) => {
9035
+ try {
9036
+ const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions);
9037
+ if (!parseResult.success) {
9038
+ const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
9039
+ console.error(`
9040
+ Invalid options:
9041
+ ${msg}`);
9042
+ process.exit(1);
9043
+ }
9044
+ const options = parseResult.data;
9045
+ if (!isFigmaUrl(input)) {
9046
+ throw new Error("calibrate-save-fixture requires a Figma URL as input.");
9047
+ }
9048
+ if (options.imageScale !== void 0) {
9049
+ const scale = Number(options.imageScale);
9050
+ if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
9051
+ console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
9052
+ process.exit(1);
9053
+ }
9054
+ }
9055
+ if (!parseFigmaUrl(input).nodeId) {
9056
+ console.warn("\nWarning: No node-id specified. Saving entire file as fixture.");
9057
+ console.warn("Tip: Add ?node-id=XXX to save a specific section.\n");
9058
+ }
9059
+ const { file } = await loadFile(input, options.token);
9060
+ file.sourceUrl = input;
9061
+ const fixtureName = options.name ?? file.fileKey;
9062
+ const fixtureDir = resolve(options.output ?? `fixtures/${fixtureName}`);
9063
+ mkdirSync(fixtureDir, { recursive: true });
9064
+ const figmaTokenForComponents = options.token ?? getFigmaToken();
9065
+ if (figmaTokenForComponents) {
9066
+ const { FigmaClient: FC } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
9067
+ const { resolveComponentDefinitions: resolveComponentDefinitions2, resolveInteractionDestinations: resolveInteractionDestinations2 } = await Promise.resolve().then(() => (init_component_resolver(), component_resolver_exports));
9068
+ const componentClient = new FC({ token: figmaTokenForComponents });
9069
+ try {
9070
+ const definitions = await resolveComponentDefinitions2(componentClient, file.fileKey, file.document);
9071
+ const count = Object.keys(definitions).length;
9072
+ if (count > 0) {
9073
+ file.componentDefinitions = definitions;
9074
+ console.log(`Resolved ${count} component master node tree(s)`);
9075
+ }
9076
+ const interactionDests = await resolveInteractionDestinations2(componentClient, file.fileKey, file.document, file.componentDefinitions);
9077
+ const destCount = Object.keys(interactionDests).length;
9078
+ if (destCount > 0) {
9079
+ file.interactionDestinations = interactionDests;
9080
+ console.log(`Resolved ${destCount} interaction destination(s)`);
9081
+ }
9082
+ } catch {
9083
+ console.warn("Warning: failed to resolve component definitions (continuing)");
9084
+ }
9085
+ }
9086
+ const dataPath = resolve(fixtureDir, "data.json");
9087
+ await writeFile(dataPath, JSON.stringify(file, null, 2), "utf-8");
9088
+ console.log(`Fixture saved: ${fixtureDir}/`);
9089
+ console.log(` data.json: ${file.name} (${countNodes2(file.document)} nodes)`);
9090
+ const figmaToken = options.token ?? getFigmaToken();
9091
+ if (figmaToken) {
9092
+ const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
9093
+ const client = new FigmaClient2({ token: figmaToken });
9094
+ const { nodeId } = parseFigmaUrl(input);
9095
+ const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
9096
+ try {
9097
+ const imageUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
9098
+ const url = imageUrls[rootNodeId];
9099
+ if (url) {
9100
+ const resp = await fetch(url);
9101
+ if (resp.ok) {
9102
+ const buffer = Buffer.from(await resp.arrayBuffer());
9103
+ const { writeFile: writeFileSync6 } = await import('fs/promises');
9104
+ await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
9105
+ console.log(` screenshot.png: saved`);
9106
+ }
9107
+ }
9108
+ } catch {
9109
+ console.warn(" screenshot.png: failed to download (continuing)");
9110
+ }
9111
+ const vectorNodes = collectVectorNodes(file.document);
9112
+ if (vectorNodes.length > 0) {
9113
+ const vectorDir = resolve(fixtureDir, "vectors");
9114
+ mkdirSync(vectorDir, { recursive: true });
9115
+ const svgUrls = await client.getNodeImages(
9116
+ file.fileKey,
9117
+ vectorNodes.map((n) => n.id),
9118
+ { format: "svg" }
9119
+ );
9120
+ const mapping = {};
9121
+ const usedNames = /* @__PURE__ */ new Map();
9122
+ let downloaded = 0;
9123
+ for (const { id, name } of vectorNodes) {
9124
+ let base = sanitizeFilename(name);
9125
+ const count = usedNames.get(base) ?? 0;
9126
+ usedNames.set(base, count + 1);
9127
+ if (count > 0) base = `${base}-${count + 1}`;
9128
+ const filename = `${base}.svg`;
9129
+ mapping[id] = filename;
9130
+ const svgUrl = svgUrls[id];
9131
+ if (!svgUrl) continue;
9132
+ try {
9133
+ const resp = await fetch(svgUrl);
9134
+ if (resp.ok) {
9135
+ const svg = await resp.text();
9136
+ await writeFile(resolve(vectorDir, filename), svg, "utf-8");
9137
+ downloaded++;
9138
+ }
9139
+ } catch {
9140
+ }
9141
+ }
9142
+ await writeFile(resolve(vectorDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
9143
+ console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
9144
+ }
9145
+ const imageNodes = collectImageNodes(file.document);
9146
+ if (imageNodes.length > 0) {
9147
+ const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
9148
+ const imageDir = resolve(fixtureDir, "images");
9149
+ mkdirSync(imageDir, { recursive: true });
9150
+ const imageFills = await client.getImageFills(file.fileKey);
9151
+ const usedNames = /* @__PURE__ */ new Map();
9152
+ const mapping = {};
9153
+ let imgDownloaded = 0;
9154
+ for (const { id, name, imageRef } of imageNodes) {
9155
+ let base = sanitizeFilename(name);
9156
+ const count = usedNames.get(base) ?? 0;
9157
+ usedNames.set(base, count + 1);
9158
+ if (count > 0) base = `${base}-${count + 1}`;
9159
+ const filename = `${base}@${imgScale}x.png`;
9160
+ mapping[id] = filename;
9161
+ if (!imageRef) continue;
9162
+ const imgUrl = imageFills[imageRef];
9163
+ if (!imgUrl) continue;
9164
+ try {
9165
+ const resp = await fetch(imgUrl);
9166
+ if (resp.ok) {
9167
+ const buf = Buffer.from(await resp.arrayBuffer());
9168
+ await writeFile(resolve(imageDir, filename), buf);
9169
+ imgDownloaded++;
9170
+ }
9171
+ } catch {
9172
+ }
9173
+ }
9174
+ await writeFile(
9175
+ resolve(imageDir, "mapping.json"),
9176
+ JSON.stringify(mapping, null, 2),
9177
+ "utf-8"
9178
+ );
9179
+ console.log(` images/: ${imgDownloaded}/${imageNodes.length} PNGs (@${imgScale}x)`);
9180
+ }
9181
+ }
8730
9182
  } catch (error) {
8731
- console.error("\nError:", error instanceof Error ? error.message : String(error));
9183
+ console.error(
9184
+ "\nError:",
9185
+ error instanceof Error ? error.message : String(error)
9186
+ );
8732
9187
  process.exitCode = 1;
8733
9188
  }
8734
9189
  });
8735
9190
  }
8736
- function sanitizeHtml(html) {
8737
- let result = html;
8738
- result = result.replace(/^\/\/\s*filename:.*\n/i, "");
8739
- result = result.replace(/<script[\s\S]*?<\/script>/gi, "");
8740
- result = result.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
8741
- result = result.replace(
8742
- /\s+(href|src|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi,
8743
- (_, attr) => ` ${attr}="#"`
8744
- );
8745
- return result;
8746
- }
8747
- function injectLocalFont(html) {
8748
- const fontPath = resolve("assets/fonts/Inter.var.woff2");
8749
- if (!existsSync(fontPath)) return html;
8750
- const fontUrl = pathToFileURL(fontPath).href;
8751
- const fontCss = `@font-face { font-family: "Inter"; src: url("${fontUrl}") format("woff2"); font-weight: 100 900; }`;
8752
- let result = html;
8753
- result = result.replace(/<link[^>]*fonts\.googleapis\.com[^>]*>/gi, "");
8754
- result = result.replace(/<link[^>]*fonts\.gstatic\.com[^>]*>/gi, "");
8755
- if (result.includes("<style>")) {
8756
- result = result.replace("<style>", `<style>
8757
- ${fontCss}
8758
- `);
8759
- } else if (result.includes("</head>")) {
8760
- result = result.replace("</head>", `<style>${fontCss}</style>
8761
- </head>`);
8762
- }
8763
- return result;
8764
- }
8765
-
8766
- // src/cli/commands/internal/html-postprocess.ts
8767
- function registerHtmlPostprocess(cli2) {
9191
+ var ImplementOptionsSchema = z.object({
9192
+ token: z.string().optional(),
9193
+ output: z.string().optional(),
9194
+ prompt: z.string().optional(),
9195
+ imageScale: z.string().optional()
9196
+ });
9197
+ function registerCalibrateImplement(cli2) {
8768
9198
  cli2.command(
8769
- "html-postprocess <input>",
8770
- "[internal] Sanitize HTML and inject local fonts"
8771
- ).option("--output <path>", "Output path (default: overwrite input)").action(async (input, options) => {
9199
+ "calibrate-implement <input>",
9200
+ "Prepare design-to-code package for calibration: analysis + design tree + assets + prompt"
9201
+ ).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory (default: ./canicode-implement/)").option("--prompt <path>", "Custom prompt file (default: built-in HTML+CSS prompt)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode calibrate-implement ./fixtures/my-design").example(" canicode calibrate-implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3").action(async (input, rawOptions) => {
8772
9202
  try {
8773
- const inputPath = resolve(input);
8774
- if (!existsSync(inputPath)) {
8775
- console.error(`Error: Input file not found: ${inputPath}`);
8776
- process.exitCode = 1;
8777
- return;
9203
+ const parseResult = ImplementOptionsSchema.safeParse(rawOptions);
9204
+ if (!parseResult.success) {
9205
+ const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
9206
+ console.error(`
9207
+ Invalid options:
9208
+ ${msg}`);
9209
+ process.exit(1);
8778
9210
  }
8779
- const raw = readFileSync(inputPath, "utf-8");
8780
- const html = injectLocalFont(sanitizeHtml(raw));
8781
- const outputPath = options.output ? resolve(options.output) : inputPath;
8782
- await writeFile(outputPath, html, "utf-8");
8783
- console.log(JSON.stringify({
8784
- inputPath,
8785
- outputPath,
8786
- inputBytes: Buffer.byteLength(raw, "utf-8"),
8787
- outputBytes: Buffer.byteLength(html, "utf-8")
8788
- }));
9211
+ const options = parseResult.data;
9212
+ if (options.imageScale !== void 0) {
9213
+ const scale = Number(options.imageScale);
9214
+ if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
9215
+ console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
9216
+ process.exit(1);
9217
+ }
9218
+ }
9219
+ if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
9220
+ console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file.");
9221
+ console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
9222
+ }
9223
+ const outputDir = resolve(options.output ?? "canicode-implement");
9224
+ mkdirSync(outputDir, { recursive: true });
9225
+ console.log("\nPreparing implementation package...\n");
9226
+ const { file } = await loadFile(input, options.token);
9227
+ console.log(`Design: ${file.name}`);
9228
+ const result = analyzeFile(file);
9229
+ const scores = calculateScores(result);
9230
+ const resultJson = buildResultJson(file.name, result, scores, { fileKey: file.fileKey });
9231
+ await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8");
9232
+ console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`);
9233
+ const fixtureBase = isJsonFile(input) || isFixtureDir(input) ? isJsonFile(input) ? dirname(resolve(input)) : resolve(input) : void 0;
9234
+ let vectorDir = fixtureBase ? resolve(fixtureBase, "vectors") : void 0;
9235
+ let imageDir = fixtureBase ? resolve(fixtureBase, "images") : void 0;
9236
+ if (vectorDir && existsSync(vectorDir)) {
9237
+ const vecOutputDir = resolve(outputDir, "vectors");
9238
+ mkdirSync(vecOutputDir, { recursive: true });
9239
+ const { readdirSync: readdirSync4, copyFileSync: copyFileSync3 } = await import('fs');
9240
+ const vecFiles = readdirSync4(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
9241
+ for (const f of vecFiles) {
9242
+ copyFileSync3(resolve(vectorDir, f), resolve(vecOutputDir, f));
9243
+ }
9244
+ vectorDir = vecOutputDir;
9245
+ console.log(` vectors/: ${vecFiles.length} SVGs copied`);
9246
+ }
9247
+ if (imageDir && existsSync(imageDir)) {
9248
+ const imgOutputDir = resolve(outputDir, "images");
9249
+ mkdirSync(imgOutputDir, { recursive: true });
9250
+ const { readdirSync: readdirSync4, copyFileSync: copyFileSync3 } = await import('fs');
9251
+ const imgFiles = readdirSync4(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
9252
+ for (const f of imgFiles) {
9253
+ copyFileSync3(resolve(imageDir, f), resolve(imgOutputDir, f));
9254
+ }
9255
+ imageDir = imgOutputDir;
9256
+ const pngCount = imgFiles.filter((f) => f.endsWith(".png")).length;
9257
+ console.log(` images/: ${pngCount} assets copied`);
9258
+ }
9259
+ if (isFigmaUrl(input) && !fixtureBase) {
9260
+ const figmaToken = options.token ?? getFigmaToken();
9261
+ if (figmaToken) {
9262
+ const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
9263
+ const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
9264
+ const client = new FigmaClient2({ token: figmaToken });
9265
+ const { nodeId } = parseFigmaUrl(input);
9266
+ const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
9267
+ try {
9268
+ const screenshotUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
9269
+ const screenshotUrl = screenshotUrls[rootNodeId];
9270
+ if (screenshotUrl) {
9271
+ const resp = await fetch(screenshotUrl);
9272
+ if (resp.ok) {
9273
+ const buf = Buffer.from(await resp.arrayBuffer());
9274
+ await writeFile(resolve(outputDir, "screenshot.png"), buf);
9275
+ console.log(` screenshot.png: saved`);
9276
+ }
9277
+ }
9278
+ } catch {
9279
+ console.warn(" screenshot.png: failed to download (continuing)");
9280
+ }
9281
+ const vectorNodes = collectVectorNodes(file.document);
9282
+ if (vectorNodes.length > 0) {
9283
+ const vecOutDir = resolve(outputDir, "vectors");
9284
+ mkdirSync(vecOutDir, { recursive: true });
9285
+ try {
9286
+ const svgUrls = await client.getNodeImages(
9287
+ file.fileKey,
9288
+ vectorNodes.map((n) => n.id),
9289
+ { format: "svg" }
9290
+ );
9291
+ const mapping = {};
9292
+ const usedNames = /* @__PURE__ */ new Map();
9293
+ let downloaded = 0;
9294
+ for (const { id, name } of vectorNodes) {
9295
+ let base = sanitizeFilename(name);
9296
+ const count = usedNames.get(base) ?? 0;
9297
+ usedNames.set(base, count + 1);
9298
+ if (count > 0) base = `${base}-${count + 1}`;
9299
+ const filename = `${base}.svg`;
9300
+ mapping[id] = filename;
9301
+ const svgUrl = svgUrls[id];
9302
+ if (!svgUrl) continue;
9303
+ try {
9304
+ const resp = await fetch(svgUrl);
9305
+ if (resp.ok) {
9306
+ const svg = await resp.text();
9307
+ await writeFile(resolve(vecOutDir, filename), svg, "utf-8");
9308
+ downloaded++;
9309
+ }
9310
+ } catch {
9311
+ }
9312
+ }
9313
+ await writeFile(resolve(vecOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
9314
+ console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
9315
+ } catch {
9316
+ console.warn(" vectors/: failed to download (continuing)");
9317
+ }
9318
+ }
9319
+ const imgNodes = collectImageNodes(file.document);
9320
+ if (imgNodes.length > 0) {
9321
+ const imgOutDir = resolve(outputDir, "images");
9322
+ mkdirSync(imgOutDir, { recursive: true });
9323
+ try {
9324
+ const imageFills = await client.getImageFills(file.fileKey);
9325
+ const mapping = {};
9326
+ const usedNames = /* @__PURE__ */ new Map();
9327
+ let downloaded = 0;
9328
+ for (const { id, name, imageRef } of imgNodes) {
9329
+ let base = sanitizeFilename(name);
9330
+ const count = usedNames.get(base) ?? 0;
9331
+ usedNames.set(base, count + 1);
9332
+ if (count > 0) base = `${base}-${count + 1}`;
9333
+ const filename = `${base}@${imgScale}x.png`;
9334
+ mapping[id] = filename;
9335
+ if (!imageRef) continue;
9336
+ const imgUrl = imageFills[imageRef];
9337
+ if (!imgUrl) continue;
9338
+ try {
9339
+ const resp = await fetch(imgUrl);
9340
+ if (resp.ok) {
9341
+ const buf = Buffer.from(await resp.arrayBuffer());
9342
+ await writeFile(resolve(imgOutDir, filename), buf);
9343
+ downloaded++;
9344
+ }
9345
+ } catch {
9346
+ }
9347
+ }
9348
+ await writeFile(resolve(imgOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
9349
+ imageDir = imgOutDir;
9350
+ console.log(` images/: ${downloaded}/${imgNodes.length} PNGs (@${imgScale}x)`);
9351
+ } catch {
9352
+ console.warn(" images/: failed to download (continuing)");
9353
+ }
9354
+ }
9355
+ const vecOutCheck = resolve(outputDir, "vectors");
9356
+ if (existsSync(vecOutCheck)) vectorDir = vecOutCheck;
9357
+ }
9358
+ }
9359
+ const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
9360
+ const treeOptions = {
9361
+ ...vectorDir && existsSync(vectorDir) ? { vectorDir } : {},
9362
+ ...imageDir && existsSync(imageDir) ? { imageDir } : {}
9363
+ };
9364
+ const stats = generateDesignTreeWithStats2(file, treeOptions);
9365
+ await writeFile(resolve(outputDir, "design-tree.txt"), stats.tree, "utf-8");
9366
+ console.log(` design-tree.txt: ~${stats.estimatedTokens} tokens`);
9367
+ if (options.prompt) {
9368
+ const { readFile: rf } = await import('fs/promises');
9369
+ const customPrompt = await rf(resolve(options.prompt), "utf-8");
9370
+ await writeFile(resolve(outputDir, "PROMPT.md"), customPrompt, "utf-8");
9371
+ console.log(` PROMPT.md: custom (${options.prompt})`);
9372
+ } else {
9373
+ const { readFile: rf } = await import('fs/promises');
9374
+ const { dirname: dirnameFn, resolve: resolveFn } = await import('path');
9375
+ const { fileURLToPath: fileURLToPath2 } = await import('url');
9376
+ const cliDir = dirnameFn(fileURLToPath2(import.meta.url));
9377
+ const projectRoot = resolveFn(cliDir, "../..");
9378
+ const promptPath = resolveFn(projectRoot, ".claude/agents/calibration/PROMPT.md");
9379
+ let prompt = "";
9380
+ try {
9381
+ prompt = await rf(promptPath, "utf-8");
9382
+ } catch {
9383
+ }
9384
+ if (prompt) {
9385
+ await writeFile(resolve(outputDir, "PROMPT.md"), prompt, "utf-8");
9386
+ console.log(` PROMPT.md: default (html-css)`);
9387
+ } else {
9388
+ console.warn(" PROMPT.md: built-in prompt not found (skipped)");
9389
+ }
9390
+ }
9391
+ console.log(`
9392
+ ${"=".repeat(50)}`);
9393
+ console.log(`Implementation package ready: ${outputDir}/`);
9394
+ console.log(` Grade: ${scores.overall.grade} (${scores.overall.percentage}%)`);
9395
+ console.log(` Issues: ${result.issues.length}`);
9396
+ console.log(` Design tree: ~${stats.estimatedTokens} tokens`);
9397
+ console.log(`${"=".repeat(50)}`);
9398
+ console.log(`
9399
+ Next: Feed design-tree.txt + PROMPT.md to your AI assistant.`);
8789
9400
  } catch (error) {
8790
9401
  console.error("\nError:", error instanceof Error ? error.message : String(error));
8791
9402
  process.exitCode = 1;
@@ -8839,14 +9450,12 @@ process.on("beforeExit", () => {
8839
9450
  shutdownMonitoring();
8840
9451
  });
8841
9452
  registerAnalyze(cli);
8842
- registerSaveFixture(cli);
9453
+ registerGotchaSurvey(cli);
8843
9454
  registerDesignTree(cli);
8844
- registerImplement(cli);
8845
9455
  registerVisualCompare(cli);
8846
9456
  registerInit(cli);
8847
9457
  registerConfig(cli);
8848
9458
  registerListRules(cli);
8849
- registerPrompt(cli);
8850
9459
  registerCalibrateAnalyze(cli);
8851
9460
  registerCalibrateEvaluate(cli);
8852
9461
  registerCalibrateGapReport(cli);
@@ -8858,11 +9467,18 @@ registerEvidenceEnrich(cli);
8858
9467
  registerEvidencePrune(cli);
8859
9468
  registerDesignTreeStrip(cli);
8860
9469
  registerHtmlPostprocess(cli);
9470
+ registerCalibrateSaveFixture(cli);
9471
+ registerCalibrateImplement(cli);
8861
9472
  registerCodeMetrics(cli);
8862
- cli.command("docs [topic]", "Show documentation (topics: setup, rules, config, visual-compare, design-tree)").action((topic) => {
9473
+ cli.command("docs [topic]", "Show documentation (topics: setup, config, scoring, rules, visual-compare, design-tree)").action((topic) => {
8863
9474
  handleDocs(topic);
8864
9475
  });
8865
9476
  cli.help((sections) => {
9477
+ for (const section of sections) {
9478
+ if (section.title === "Commands" || section.title?.startsWith("For more info")) {
9479
+ section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
9480
+ }
9481
+ }
8866
9482
  sections.push(
8867
9483
  {
8868
9484
  title: "\nSetup",
@@ -8884,7 +9500,8 @@ cli.help((sections) => {
8884
9500
  body: [
8885
9501
  ` $ canicode analyze "https://www.figma.com/design/..." --api`,
8886
9502
  ` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
8887
- ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`
9503
+ ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
9504
+ ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`
8888
9505
  ].join("\n")
8889
9506
  },
8890
9507
  {