canicode 0.11.0 → 0.11.2

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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, copyFileSync, readdirSync, renameSync, chmodSync } from 'fs';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, renameSync, chmodSync, copyFileSync } from 'fs';
3
3
  import { join, resolve, dirname, basename, relative } from 'path';
4
4
  import pixelmatch from 'pixelmatch';
5
5
  import { PNG } from 'pngjs';
@@ -1377,7 +1377,9 @@ var EVENTS = {
1377
1377
  // cannot import `core/monitoring` directly, so the event fires through a
1378
1378
  // caller-supplied callback. Define the typed name here so a future consumer
1379
1379
  // has a single place to wire it up.
1380
- ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
1380
+ ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
1381
+ /** CLI `canicode roundtrip-tally` completed successfully. */
1382
+ ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
1381
1383
  };
1382
1384
 
1383
1385
  // src/core/monitoring/capture.ts
@@ -1529,6 +1531,14 @@ function printDocsSetup() {
1529
1531
  console.log(`
1530
1532
  CANICODE SETUP GUIDE
1531
1533
 
1534
+ Skills at a glance: canicode-gotchas = survey answers saved locally (memo-only).
1535
+ canicode-roundtrip = same flow plus writes to Figma via use_figma (canvas).
1536
+
1537
+ Token safety: Do NOT paste your Figma token into Claude, Cursor, or other
1538
+ agent chats \u2014 transcripts can retain it. Use:
1539
+ FIGMA_TOKEN=figd_\u2026 npx canicode init
1540
+ or run \`npx canicode init\` and enter the token only at the CLI prompt.
1541
+
1532
1542
  \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
1533
1543
  1. CLI (REST API)
1534
1544
  \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
@@ -1538,7 +1548,11 @@ CANICODE SETUP GUIDE
1538
1548
 
1539
1549
  Setup:
1540
1550
  canicode init --token figd_xxxxxxxxxxxxx
1541
- (saves token + installs skills \u2014 see section 2 for --no-skills)
1551
+ (saves token + installs Claude Code skills into ./.claude/skills/)
1552
+
1553
+ Skills only (no token yet):
1554
+ canicode init --cursor-skills
1555
+ (installs Claude skills + Cursor copies; run init --token \u2026 before live Figma REST URLs)
1542
1556
 
1543
1557
  Use:
1544
1558
  canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
@@ -1548,6 +1562,7 @@ CANICODE SETUP GUIDE
1548
1562
  --preset strict|relaxed|dev-friendly|ai-ready
1549
1563
  --config ./my-config.json
1550
1564
  --no-open Don't open report in browser
1565
+ --api No-op for Figma URLs (REST always); same flag as gotcha-survey (#461)
1551
1566
 
1552
1567
  Output:
1553
1568
  ~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
@@ -1556,6 +1571,8 @@ CANICODE SETUP GUIDE
1556
1571
  2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
1557
1572
  \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
1573
 
1574
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1575
+
1559
1576
  Setup:
1560
1577
  canicode init --token figd_xxxxxxxxxxxxx
1561
1578
  (installs three skills into ./.claude/skills/ alongside the token)
@@ -1567,7 +1584,7 @@ CANICODE SETUP GUIDE
1567
1584
 
1568
1585
  Flags:
1569
1586
  --global Install to ~/.claude/skills/ instead of ./.claude/skills/
1570
- --no-skills Skip skill install (token only \u2014 legacy behavior)
1587
+ --cursor-skills Also install Cursor copies (see \xA73)
1571
1588
  --force Overwrite existing skill files without prompting
1572
1589
 
1573
1590
  Use (in Claude Code):
@@ -1575,6 +1592,43 @@ CANICODE SETUP GUIDE
1575
1592
  /canicode-gotchas <url> Run a gotcha survey
1576
1593
  /canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
1577
1594
 
1595
+ \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
1596
+ 3. CURSOR SKILLS (requires FIGMA_TOKEN)
1597
+ \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
1598
+
1599
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1600
+
1601
+ Setup:
1602
+ canicode init --token figd_xxxxxxxxxxxxx --cursor-skills
1603
+ (installs Cursor copies of the three skills into ./.cursor/skills/)
1604
+
1605
+ Installed skills:
1606
+ canicode Lightweight CLI wrapper
1607
+ canicode-gotchas Standalone gotcha survey
1608
+ canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
1609
+
1610
+ Flags:
1611
+ --cursor-skills Install Cursor copies of all three skills into .cursor/skills/
1612
+ (with --token, runs after full Claude skill install; without token,
1613
+ installs Claude skills under .claude/skills/ first, then Cursor copies)
1614
+ --force Overwrite existing skill files without prompting
1615
+
1616
+ Use (in Cursor Agent chat):
1617
+ @canicode <figma-url>
1618
+ @canicode-gotchas <figma-url> Run a gotcha survey
1619
+ @canicode-roundtrip <figma-url> Analyze, fix gotchas in Figma, re-analyze
1620
+
1621
+ Invocation: docs default to @-skills for Agent chat. If your team uses slash
1622
+ commands or Cursor rules instead, use those \u2014 capability is the same once MCP
1623
+ + skills load.
1624
+
1625
+ MCP files: Cursor reads .cursor/mcp.json (or ~/.cursor/mcp.json), not the
1626
+ repo-root .mcp.json used by Claude Code \u2014 duplicate Figma + canicode entries
1627
+ if you use both hosts (see docs/CUSTOMIZATION.md#cursor-mcp-canicode).
1628
+
1629
+ See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
1630
+ writes; analyze-only works without it).
1631
+
1578
1632
  \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
1579
1633
  TOKEN PRIORITY
1580
1634
  \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
@@ -1590,6 +1644,7 @@ CANICODE SETUP GUIDE
1590
1644
  CI/CD, automation -> CLI + FIGMA_TOKEN env var
1591
1645
  Claude Code (full) -> canicode MCP server + FIGMA_TOKEN
1592
1646
  Claude Code (light) -> /canicode skill + FIGMA_TOKEN
1647
+ Cursor Agent -> canicode init --cursor-skills + Figma MCP
1593
1648
  In Figma -> Figma Plugin
1594
1649
  Browser -> Web App (GitHub Pages)
1595
1650
  Quick trial, offline -> CLI + JSON fixtures
@@ -3520,9 +3575,27 @@ defineRule({
3520
3575
  definition: missingPrototypeDef,
3521
3576
  check: missingPrototypeCheck
3522
3577
  });
3578
+ var AcknowledgmentIntentSchema = z.object({
3579
+ field: z.string(),
3580
+ value: z.unknown(),
3581
+ scope: z.enum(["instance", "definition"])
3582
+ });
3583
+ var AcknowledgmentSceneWriteOutcomeSchema = z.object({
3584
+ result: z.enum([
3585
+ "succeeded",
3586
+ "silent-ignored",
3587
+ "api-rejected",
3588
+ "user-declined-propagation",
3589
+ "unknown"
3590
+ ]),
3591
+ reason: z.string().optional()
3592
+ });
3523
3593
  var AcknowledgmentSchema = z.object({
3524
3594
  nodeId: z.string(),
3525
- ruleId: z.string()
3595
+ ruleId: z.string(),
3596
+ intent: AcknowledgmentIntentSchema.optional(),
3597
+ sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
3598
+ codegenDirective: z.string().optional()
3526
3599
  });
3527
3600
  var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
3528
3601
  function normalizeNodeId(id) {
@@ -4172,7 +4245,7 @@ function computeApplyContext(violation, instanceContext) {
4172
4245
  }
4173
4246
 
4174
4247
  // package.json
4175
- var version2 = "0.11.0";
4248
+ var version2 = "0.11.2";
4176
4249
 
4177
4250
  // src/core/engine/scoring.ts
4178
4251
  function computeTotalScorePerCategory(configs) {
@@ -5651,6 +5724,7 @@ var AnalyzeOptionsSchema = z.object({
5651
5724
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
5652
5725
  output: z.string().optional(),
5653
5726
  token: z.string().optional(),
5727
+ /** Accepted for CLI parity; Figma URL loads always use the REST API today (#461). */
5654
5728
  api: z.boolean().optional(),
5655
5729
  screenshot: z.boolean().optional(),
5656
5730
  config: z.string().optional(),
@@ -5660,7 +5734,10 @@ var AnalyzeOptionsSchema = z.object({
5660
5734
  scope: z.enum(["page", "component"]).optional()
5661
5735
  });
5662
5736
  function registerAnalyze(cli2) {
5663
- cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371) Path to a JSON file containing [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations. Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
5737
+ cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option(
5738
+ "--api",
5739
+ "No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `gotcha-survey` (#461)."
5740
+ ).option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371 / ADR-019) Path to JSON acknowledgments from canicode Figma annotations (nodeId, ruleId; optional intent / sceneWriteOutcome / codegenDirective per #444). Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
5664
5741
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5665
5742
  if (!parseResult.success) {
5666
5743
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -5670,6 +5747,7 @@ ${msg}`);
5670
5747
  process.exit(1);
5671
5748
  }
5672
5749
  const options = parseResult.data;
5750
+ void options.api;
5673
5751
  const analysisStart = Date.now();
5674
5752
  trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" });
5675
5753
  const log = options.json ? console.error.bind(console) : console.log.bind(console);
@@ -5946,7 +6024,13 @@ var BATCHABLE_RULE_IDS = [
5946
6024
  "no-auto-layout",
5947
6025
  "fixed-size-in-auto-layout"
5948
6026
  ];
6027
+ var OPT_IN_BATCHABLE_RULE_IDS = [
6028
+ "missing-prototype"
6029
+ ];
5949
6030
  var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
6031
+ var OPT_IN_BATCHABLE_SET = new Set(
6032
+ OPT_IN_BATCHABLE_RULE_IDS
6033
+ );
5950
6034
  var NO_SOURCE_SENTINEL = "_no-source";
5951
6035
  function groupAndBatchSurveyQuestions(questions) {
5952
6036
  if (questions.length === 0) {
@@ -5987,20 +6071,25 @@ function sourceComponentKey(question) {
5987
6071
  }
5988
6072
  function pushIntoBatch(group, question) {
5989
6073
  const sceneWeight = Math.max(question.replicas ?? 1, 1);
5990
- const isBatchable = BATCHABLE_SET.has(question.ruleId);
6074
+ const batchMode = resolveBatchMode(question.ruleId);
5991
6075
  const last = group.batches.at(-1);
5992
- if (last !== void 0 && last.ruleId === question.ruleId && isBatchable && last.batchable) {
6076
+ if (last !== void 0 && last.ruleId === question.ruleId && batchMode !== "none" && last.batchMode === batchMode) {
5993
6077
  last.questions.push(question);
5994
6078
  last.totalScenes += sceneWeight;
5995
6079
  return;
5996
6080
  }
5997
6081
  group.batches.push({
5998
6082
  ruleId: question.ruleId,
5999
- batchable: isBatchable,
6083
+ batchMode,
6000
6084
  questions: [question],
6001
6085
  totalScenes: sceneWeight
6002
6086
  });
6003
6087
  }
6088
+ function resolveBatchMode(ruleId) {
6089
+ if (BATCHABLE_SET.has(ruleId)) return "safe";
6090
+ if (OPT_IN_BATCHABLE_SET.has(ruleId)) return "opt-in";
6091
+ return "none";
6092
+ }
6004
6093
 
6005
6094
  // src/core/gotcha/survey-generator.ts
6006
6095
  var NODE_PATH_SEPARATOR = " > ";
@@ -6019,12 +6108,18 @@ function generateGotchaSurvey(result, scores, options = {}) {
6019
6108
  const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
6020
6109
  const questions = deduplicateBySourceComponent(mapped);
6021
6110
  const groupedQuestions = groupAndBatchSurveyQuestions(questions);
6111
+ const PROPAGATION_CANDIDATE_THRESHOLD = 3;
6112
+ const propagationCandidates = questions.filter(
6113
+ (q) => q.isInstanceChild
6114
+ ).length;
6115
+ const suggestedDefaultApply = propagationCandidates >= PROPAGATION_CANDIDATE_THRESHOLD;
6022
6116
  return {
6023
6117
  designGrade: grade,
6024
6118
  isReadyForCodeGen: isReadyForCodeGen(grade),
6025
6119
  questions,
6026
6120
  groupedQuestions,
6027
- designKey: options.designKey ?? ""
6121
+ designKey: options.designKey ?? "",
6122
+ suggestedDefaultApply
6028
6123
  };
6029
6124
  }
6030
6125
  function deduplicateSiblingIssues(issues) {
@@ -6173,12 +6268,15 @@ function findNodeById2(node, id) {
6173
6268
  var GotchaSurveyOptionsSchema = z.object({
6174
6269
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
6175
6270
  token: z.string().optional(),
6271
+ /** Accepted for CLI parity with `analyze`; Figma URL loads always use REST (#461). */
6272
+ api: z.boolean().optional(),
6176
6273
  config: z.string().optional(),
6177
6274
  targetNodeId: z.string().optional(),
6178
6275
  json: z.boolean().optional(),
6179
6276
  scope: z.enum(["page", "component"]).optional()
6180
6277
  });
6181
6278
  async function runGotchaSurvey(input, options) {
6279
+ void options.api;
6182
6280
  const { file, nodeId } = await loadFile(input, options.token);
6183
6281
  const effectiveNodeId = options.targetNodeId ?? nodeId;
6184
6282
  let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
@@ -6207,7 +6305,10 @@ function formatHumanSummary(survey) {
6207
6305
  return lines.join("\n");
6208
6306
  }
6209
6307
  function registerGotchaSurvey(cli2) {
6210
- 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("--scope <scope>", "(#404) Override analysis scope: `page` or `component`. Defaults to auto-detection from the root node type.").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) => {
6308
+ 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(
6309
+ "--api",
6310
+ "No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `analyze` (#461)."
6311
+ ).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("--scope <scope>", "(#404) Override analysis scope: `page` or `component`. Defaults to auto-detection from the root node type.").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) => {
6211
6312
  const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
6212
6313
  if (!parseResult.success) {
6213
6314
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -6260,6 +6361,239 @@ ${msg}`);
6260
6361
  }
6261
6362
  });
6262
6363
  }
6364
+ var DetectionSchema = z.literal("rule-based");
6365
+ var OutputChannelSchema = z.enum(["score", "annotation"]);
6366
+ var PersistenceIntentSchema = z.enum(["transient", "durable"]);
6367
+ var RulePurposeSchema = z.enum(["violation", "info-collection"]);
6368
+
6369
+ // src/core/contracts/gotcha-survey.ts
6370
+ var GradeSchema = z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]);
6371
+ var InstanceContextSchema = z.object({
6372
+ parentInstanceNodeId: z.string(),
6373
+ sourceNodeId: z.string(),
6374
+ sourceComponentId: z.string().optional(),
6375
+ sourceComponentName: z.string().optional()
6376
+ });
6377
+ var RuleApplyStrategySchema = z.enum([
6378
+ "property-mod",
6379
+ "structural-mod",
6380
+ "annotation",
6381
+ "auto-fix"
6382
+ ]);
6383
+ var TargetPropertySchema = z.union([z.string(), z.array(z.string())]);
6384
+ var AnnotationPropertySchema = z.object({ type: z.string() });
6385
+ var GotchaDetectionSchema = DetectionSchema;
6386
+ var GotchaOutputChannelSchema = OutputChannelSchema.extract([
6387
+ "annotation"
6388
+ ]);
6389
+ var GotchaPersistenceIntentSchema = PersistenceIntentSchema.extract([
6390
+ "durable"
6391
+ ]);
6392
+ var GotchaSurveyQuestionSchema = z.object({
6393
+ nodeId: z.string(),
6394
+ nodeName: z.string(),
6395
+ ruleId: z.string(),
6396
+ detection: GotchaDetectionSchema,
6397
+ outputChannel: GotchaOutputChannelSchema,
6398
+ persistenceIntent: GotchaPersistenceIntentSchema,
6399
+ /**
6400
+ * #406: Classifies the triggering rule as `violation` (score-primary,
6401
+ * gotcha secondary) or `info-collection` (annotation-primary, score
6402
+ * minimal). Consumers use this to prioritize answers that collect durable
6403
+ * implementation context over answers that merely describe how to fix a
6404
+ * violation the rule will stop firing for.
6405
+ */
6406
+ purpose: RulePurposeSchema,
6407
+ severity: SeveritySchema,
6408
+ question: z.string(),
6409
+ hint: z.string(),
6410
+ example: z.string(),
6411
+ instanceContext: InstanceContextSchema.optional(),
6412
+ applyStrategy: RuleApplyStrategySchema,
6413
+ targetProperty: TargetPropertySchema.optional(),
6414
+ annotationProperties: z.array(AnnotationPropertySchema).optional(),
6415
+ suggestedName: z.string().optional(),
6416
+ isInstanceChild: z.boolean(),
6417
+ sourceChildId: z.string().optional(),
6418
+ // #356: when this question collapses N instance-child issues that share the
6419
+ // same `(sourceComponentId, sourceNodeId, ruleId)` tuple, `replicas` is the
6420
+ // total instance count (>=2) and `replicaNodeIds` lists every instance scene
6421
+ // node id OTHER than the kept `nodeId`. Apply step iterates
6422
+ // `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
6423
+ // Single-instance questions omit both fields.
6424
+ replicas: z.number().int().min(2).optional(),
6425
+ replicaNodeIds: z.array(z.string()).optional()
6426
+ });
6427
+ var SurveyQuestionBatchSchema = z.object({
6428
+ ruleId: z.string(),
6429
+ /**
6430
+ * Rendering mode for this batch (see `BatchMode` in
6431
+ * `src/core/gotcha/group-and-batch-questions.ts` — the authoritative
6432
+ * whitelists `BATCHABLE_RULE_IDS` and `OPT_IN_BATCHABLE_RULE_IDS` live
6433
+ * there):
6434
+ * - `"safe"` — one answer uniformly applies to every member (#369).
6435
+ * - `"opt-in"` — one shared answer is a suggested default; the user may
6436
+ * reply `split` for per-node override (#426).
6437
+ * - `"none"` — single-member batch, renders the standard per-question
6438
+ * template.
6439
+ */
6440
+ batchMode: z.enum(["safe", "opt-in", "none"]),
6441
+ questions: z.array(GotchaSurveyQuestionSchema),
6442
+ /**
6443
+ * Sum of `max(question.replicas, 1)` across `questions`. Counts the
6444
+ * actual Figma scene fan-out so the SKILL can render `N instances`
6445
+ * accurately even when one batch member already collapses multiple
6446
+ * replicas via the #356 source-component dedupe.
6447
+ */
6448
+ totalScenes: z.number().int().min(1)
6449
+ });
6450
+ var SurveyQuestionGroupSchema = z.object({
6451
+ /**
6452
+ * Shared `instanceContext` for the group, or `null` for the trailing
6453
+ * non-instance group. The SKILL emits the verbose "Instance note" header
6454
+ * once per non-null group instead of once per question (#370).
6455
+ */
6456
+ instanceContext: InstanceContextSchema.nullable(),
6457
+ batches: z.array(SurveyQuestionBatchSchema)
6458
+ });
6459
+ var GroupedSurveySchema = z.object({
6460
+ groups: z.array(SurveyQuestionGroupSchema)
6461
+ });
6462
+ var GotchaSurveySchema = z.object({
6463
+ designGrade: GradeSchema,
6464
+ isReadyForCodeGen: z.boolean(),
6465
+ questions: z.array(GotchaSurveyQuestionSchema),
6466
+ groupedQuestions: GroupedSurveySchema,
6467
+ /**
6468
+ * #384 — canonical identifier for this design across canicode runs.
6469
+ * Computed by `computeDesignKey(input)` (`<fileKey>#<nodeId>` for Figma
6470
+ * URLs, absolute path for fixtures). The `canicode-gotchas` SKILL reads
6471
+ * this directly when upserting the per-design section, so the SKILL.md
6472
+ * prose no longer parses URLs (per ADR-016).
6473
+ */
6474
+ designKey: z.string(),
6475
+ /**
6476
+ * #428 — threshold hint for the `allowDefinitionWrite` picker in the
6477
+ * `canicode-roundtrip` skill. `true` when `propagationCandidates >= 3`
6478
+ * (i.e. three or more questions target instance children that could
6479
+ * benefit from definition-level writes). When `false`, the skill silently
6480
+ * uses the annotation default (ADR-012) without surfacing the picker —
6481
+ * the opt-in flow is over-engineered for tiny surveys.
6482
+ *
6483
+ * Computed server-side from `questions` so the skill doesn't have to
6484
+ * count `isInstanceChild` manually; the skill may still override this
6485
+ * hint when it has additional context (e.g. all candidates are
6486
+ * read-only per probe result).
6487
+ */
6488
+ suggestedDefaultApply: z.boolean()
6489
+ });
6490
+ var AnswersMapSchema = z.record(
6491
+ z.string(),
6492
+ z.union([
6493
+ z.object({ answer: z.string() }),
6494
+ z.object({ skipped: z.literal(true) })
6495
+ ])
6496
+ );
6497
+ var RenderGotchaSectionInputSchema = z.object({
6498
+ questions: z.array(GotchaSurveyQuestionSchema),
6499
+ answers: AnswersMapSchema,
6500
+ designName: z.string(),
6501
+ figmaUrl: z.string(),
6502
+ designKey: z.string(),
6503
+ designGrade: z.string(),
6504
+ analyzedAt: z.string(),
6505
+ /** Local date for the section header (`YYYY-MM-DD`). */
6506
+ today: z.string()
6507
+ });
6508
+ function isSkippedAnswer(nodeId, answers) {
6509
+ const v = answers[nodeId];
6510
+ if (v === void 0) return true;
6511
+ if ("skipped" in v && v.skipped === true) return true;
6512
+ if ("answer" in v) return false;
6513
+ return true;
6514
+ }
6515
+ function skippedCountsByRule(skippedQs) {
6516
+ const m = /* @__PURE__ */ new Map();
6517
+ for (const q of skippedQs) {
6518
+ m.set(q.ruleId, (m.get(q.ruleId) ?? 0) + 1);
6519
+ }
6520
+ return m;
6521
+ }
6522
+ function renderSkippedCompact(skippedQs) {
6523
+ const n = skippedQs.length;
6524
+ const counts = skippedCountsByRule(skippedQs);
6525
+ const lines = [`#### Skipped (${n})`, ""];
6526
+ const sortedRules = [...counts.keys()].sort((a, b) => a.localeCompare(b));
6527
+ for (const ruleId of sortedRules) {
6528
+ const c = counts.get(ruleId) ?? 0;
6529
+ lines.push(`- \`${ruleId}\` \xD7 ${c}`);
6530
+ }
6531
+ lines.push("");
6532
+ return lines.join("\n");
6533
+ }
6534
+ function renderInstanceContextBullet(q) {
6535
+ const ic = q.instanceContext;
6536
+ if (!ic) return null;
6537
+ let componentPart = "";
6538
+ if (ic.sourceComponentName !== void 0 && ic.sourceComponentId !== void 0) {
6539
+ componentPart = `, component \`${ic.sourceComponentName}\` / \`${ic.sourceComponentId}\``;
6540
+ } else if (ic.sourceComponentName !== void 0) {
6541
+ componentPart = `, component \`${ic.sourceComponentName}\``;
6542
+ } else if (ic.sourceComponentId !== void 0) {
6543
+ componentPart = `, component \`${ic.sourceComponentId}\``;
6544
+ }
6545
+ return `- **Instance context**: parent instance \`${ic.parentInstanceNodeId}\`, source node \`${ic.sourceNodeId}\`${componentPart} \u2014 roundtrip apply uses this to write on the source definition when instance overrides fail.`;
6546
+ }
6547
+ function renderGotchaSection(raw) {
6548
+ const input = RenderGotchaSectionInputSchema.parse(raw);
6549
+ const header = [
6550
+ `## #{{SECTION_NUMBER}} \u2014 ${input.designName} \u2014 ${input.today}`,
6551
+ "",
6552
+ `- **Figma URL**: ${input.figmaUrl}`,
6553
+ `- **Design key**: ${input.designKey}`,
6554
+ `- **Grade**: ${input.designGrade}`,
6555
+ `- **Analyzed at**: ${input.analyzedAt}`,
6556
+ "",
6557
+ "### Gotchas",
6558
+ ""
6559
+ ].join("\n");
6560
+ const answered = [];
6561
+ const skippedList = [];
6562
+ for (const q of input.questions) {
6563
+ if (isSkippedAnswer(q.nodeId, input.answers)) skippedList.push(q);
6564
+ else answered.push(q);
6565
+ }
6566
+ const blocks = [];
6567
+ for (const q of answered) {
6568
+ const v = input.answers[q.nodeId];
6569
+ if (v === void 0 || !("answer" in v)) {
6570
+ throw new Error(
6571
+ `renderGotchaSection: expected answer for nodeId ${q.nodeId} (answered set)`
6572
+ );
6573
+ }
6574
+ const answerLine = v.answer;
6575
+ const lines = [
6576
+ `#### ${q.ruleId} \u2014 ${q.nodeName}`,
6577
+ "",
6578
+ `- **Severity**: ${q.severity}`,
6579
+ `- **Node ID**: ${q.nodeId}`
6580
+ ];
6581
+ const icBullet = renderInstanceContextBullet(q);
6582
+ if (icBullet !== null) {
6583
+ lines.push(icBullet);
6584
+ }
6585
+ lines.push(
6586
+ `- **Question**: ${q.question}`,
6587
+ `- **Answer**: ${answerLine}`,
6588
+ ""
6589
+ );
6590
+ blocks.push(lines.join("\n"));
6591
+ }
6592
+ if (skippedList.length > 0) {
6593
+ blocks.push(renderSkippedCompact(skippedList));
6594
+ }
6595
+ return `${header}${blocks.join("")}`.replace(/\s+$/, "") + "\n";
6596
+ }
6263
6597
  z.enum([
6264
6598
  "missing",
6265
6599
  "valid",
@@ -6376,10 +6710,26 @@ function ensureTrailingNewline(s) {
6376
6710
  }
6377
6711
 
6378
6712
  // src/cli/commands/upsert-gotcha-section.ts
6713
+ var AnswerSchema = z.union([
6714
+ z.object({ answer: z.string() }),
6715
+ z.object({ skipped: z.literal(true) })
6716
+ ]);
6717
+ var UpsertJsonPayloadSchema = z.object({
6718
+ survey: GotchaSurveySchema.pick({
6719
+ designKey: true,
6720
+ designGrade: true,
6721
+ questions: true
6722
+ }),
6723
+ answers: z.record(z.string(), AnswerSchema),
6724
+ designName: z.string(),
6725
+ figmaUrl: z.string(),
6726
+ analyzedAt: z.string(),
6727
+ today: z.string()
6728
+ });
6379
6729
  var UpsertOptionsSchema = z.object({
6380
6730
  file: z.string().min(1, "--file is required"),
6381
6731
  designKey: z.string().min(1, "--design-key is required"),
6382
- section: z.string().min(1, "--section is required (use '-' to read stdin)")
6732
+ input: z.string().min(1, "--input is required (use '--input=-' to read stdin)")
6383
6733
  });
6384
6734
  var USER_MESSAGES = {
6385
6735
  missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
@@ -6392,8 +6742,38 @@ async function readStdin() {
6392
6742
  }
6393
6743
  return Buffer.concat(chunks).toString("utf-8");
6394
6744
  }
6745
+ function parseUpsertPayload(rawJson) {
6746
+ let parsed;
6747
+ try {
6748
+ parsed = JSON.parse(rawJson);
6749
+ } catch {
6750
+ throw new Error("Invalid JSON in --input");
6751
+ }
6752
+ const result = UpsertJsonPayloadSchema.safeParse(parsed);
6753
+ if (!result.success) {
6754
+ const msg = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
6755
+ throw new Error(`Invalid upsert payload: ${msg}`);
6756
+ }
6757
+ return result.data;
6758
+ }
6395
6759
  async function runUpsertGotchaSection(options) {
6396
- const sectionMarkdown = options.section === "-" ? await readStdin() : options.section;
6760
+ const rawJson = options.input === "-" ? await readStdin() : readFileSync(options.input, "utf-8");
6761
+ const payload = parseUpsertPayload(rawJson);
6762
+ if (payload.survey.designKey !== options.designKey) {
6763
+ throw new Error(
6764
+ `--design-key (${options.designKey}) does not match survey.designKey (${payload.survey.designKey})`
6765
+ );
6766
+ }
6767
+ const sectionMarkdown = renderGotchaSection({
6768
+ questions: payload.survey.questions,
6769
+ answers: payload.answers,
6770
+ designName: payload.designName,
6771
+ figmaUrl: payload.figmaUrl,
6772
+ designKey: payload.survey.designKey,
6773
+ designGrade: payload.survey.designGrade,
6774
+ analyzedAt: payload.analyzedAt,
6775
+ today: payload.today
6776
+ });
6397
6777
  const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
6398
6778
  const { state, newContent, plan } = renderUpsertedFile({
6399
6779
  currentContent,
@@ -6406,7 +6786,8 @@ async function runUpsertGotchaSection(options) {
6406
6786
  action: null,
6407
6787
  sectionNumber: null,
6408
6788
  wrote: false,
6409
- userMessage: USER_MESSAGES[state] ?? null
6789
+ userMessage: USER_MESSAGES[state] ?? null,
6790
+ designKey: payload.survey.designKey
6410
6791
  };
6411
6792
  }
6412
6793
  writeFileSync(options.file, newContent, "utf-8");
@@ -6415,7 +6796,8 @@ async function runUpsertGotchaSection(options) {
6415
6796
  action: plan?.action ?? null,
6416
6797
  sectionNumber: plan?.sectionNumber ?? null,
6417
6798
  wrote: true,
6418
- userMessage: null
6799
+ userMessage: null,
6800
+ designKey: payload.survey.designKey
6419
6801
  };
6420
6802
  }
6421
6803
  function registerUpsertGotchaSection(cli2) {
@@ -6426,8 +6808,8 @@ function registerUpsertGotchaSection(cli2) {
6426
6808
  "--design-key <key>",
6427
6809
  "Canonical design key from gotcha-survey's response"
6428
6810
  ).option(
6429
- "--section <markdown>",
6430
- "Already-rendered per-design section markdown. Use '-' to read from stdin."
6811
+ "--input <path>",
6812
+ "JSON payload path, or '--input=-' to read JSON from stdin (cac parses a bare '-' as a flag, so the '=' form is required)."
6431
6813
  ).action(async (rawOptions) => {
6432
6814
  const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
6433
6815
  if (!parseResult.success) {
@@ -6585,52 +6967,6 @@ function defaultSourceDir() {
6585
6967
  function defaultCursorBundleRoot() {
6586
6968
  return fileURLToPath(new URL("../../skills/cursor", import.meta.url));
6587
6969
  }
6588
- async function copySkillTree(skillName, srcSkillDir, destSkillDir, force) {
6589
- if (!existsSync(srcSkillDir)) {
6590
- throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
6591
- }
6592
- mkdirSync(destSkillDir, { recursive: true });
6593
- const ops = [];
6594
- const files = listFilesRecursive(srcSkillDir);
6595
- for (const relPath of files) {
6596
- const src = join(srcSkillDir, relPath);
6597
- const dest = join(destSkillDir, relPath);
6598
- mkdirSync(dirname(dest), { recursive: true });
6599
- const label = join(skillName, relPath);
6600
- let action;
6601
- if (!existsSync(dest)) {
6602
- action = "install";
6603
- } else if (force) {
6604
- action = "force-overwrite";
6605
- } else {
6606
- action = "needs-decision";
6607
- }
6608
- ops.push({ src, dest, label, action });
6609
- }
6610
- const candidates = ops.filter((op) => op.action === "needs-decision");
6611
- const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
6612
- const installed = [];
6613
- const overwritten = [];
6614
- const skipped = [];
6615
- for (const op of ops) {
6616
- if (op.action === "install") {
6617
- copyFileSync(op.src, op.dest);
6618
- installed.push(op.label);
6619
- } else if (op.action === "force-overwrite") {
6620
- copyFileSync(op.src, op.dest);
6621
- overwritten.push(op.label);
6622
- } else {
6623
- const decision = decisions.get(op.label) ?? "skip";
6624
- if (decision === "overwrite") {
6625
- copyFileSync(op.src, op.dest);
6626
- overwritten.push(op.label);
6627
- } else {
6628
- skipped.push(op.label);
6629
- }
6630
- }
6631
- }
6632
- return { installed, overwritten, skipped };
6633
- }
6634
6970
  async function copyMultipleSkillTrees(entries, force) {
6635
6971
  const ops = [];
6636
6972
  for (const { skillName, srcSkillDir, destSkillDir } of entries) {
@@ -6741,35 +7077,6 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
6741
7077
  }
6742
7078
  return summary;
6743
7079
  }
6744
- var InstallClaudeGotchasOnlySchema = z.object({
6745
- force: z.boolean(),
6746
- cwd: z.string().optional(),
6747
- sourceDir: z.string().optional()
6748
- });
6749
- async function installClaudeGotchasSkillOnly(rawOptions) {
6750
- const options = InstallClaudeGotchasOnlySchema.parse(rawOptions);
6751
- const sourceDir = options.sourceDir ?? defaultSourceDir();
6752
- const skillName = "canicode-gotchas";
6753
- const srcSkillDir = join(sourceDir, skillName);
6754
- const cwd = options.cwd ?? process.cwd();
6755
- const targetDir = join(cwd, ".claude", "skills");
6756
- const destSkillDir = join(targetDir, skillName);
6757
- if (!existsSync(sourceDir)) {
6758
- throw new Error(
6759
- `Bundled skills directory not found: ${sourceDir}
6760
- If you are developing canicode, run 'pnpm build' first.
6761
- If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
6762
- );
6763
- }
6764
- mkdirSync(targetDir, { recursive: true });
6765
- const part = await copySkillTree(skillName, srcSkillDir, destSkillDir, options.force);
6766
- return {
6767
- installed: part.installed,
6768
- overwritten: part.overwritten,
6769
- skipped: part.skipped,
6770
- targetDir
6771
- };
6772
- }
6773
7080
  var InstallCursorBundledSchema = z.object({
6774
7081
  force: z.boolean(),
6775
7082
  cwd: z.string().optional(),
@@ -6894,14 +7201,16 @@ function formatNextSteps(opts) {
6894
7201
  "",
6895
7202
  " Next:",
6896
7203
  " 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
6897
- " 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)"
7204
+ " 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)",
7205
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode (project `.cursor/mcp.json`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
6898
7206
  ].join("\n");
6899
7207
  }
6900
7208
  return [
6901
7209
  "",
6902
7210
  " Next:",
6903
7211
  " 1. Restart Claude Code (the newly installed skills only load on a fresh session)",
6904
- " 2. Run /canicode-roundtrip <figma-url>"
7212
+ " 2. Run /canicode-roundtrip <figma-url>",
7213
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so `analyze` / `gotcha-survey` tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
6905
7214
  ].join("\n");
6906
7215
  }
6907
7216
  if (cursor) {
@@ -6910,7 +7219,8 @@ function formatNextSteps(opts) {
6910
7219
  " Next:",
6911
7220
  " 1. Add Figma MCP to .cursor/mcp.json (see https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode and Figma MCP docs)",
6912
7221
  " 2. Restart Cursor so Figma tools (e.g. use_figma) load",
6913
- " 3. @ canicode-roundtrip with your Figma URL for full roundtrip"
7222
+ " 3. @ canicode-roundtrip with your Figma URL for full roundtrip",
7223
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per the Customization guide (`#cursor-mcp-canicode`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
6914
7224
  ].join("\n");
6915
7225
  }
6916
7226
  return [
@@ -6919,20 +7229,82 @@ function formatNextSteps(opts) {
6919
7229
  " 1. Install Figma MCP:",
6920
7230
  " claude mcp add -s project -t http figma https://mcp.figma.com/mcp",
6921
7231
  " 2. Restart Claude Code (so the new skills + Figma MCP tools both load)",
6922
- " 3. Run /canicode-roundtrip <figma-url>"
7232
+ " 3. Run /canicode-roundtrip <figma-url>",
7233
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so MCP tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
6923
7234
  ].join("\n");
6924
7235
  }
6925
7236
  var InitOptionsSchema = z.object({
6926
7237
  token: z.string().optional(),
6927
7238
  global: z.boolean().optional(),
6928
- // cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
6929
- skills: z.boolean().optional(),
6930
7239
  /** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
6931
7240
  cursorSkills: z.boolean().optional(),
6932
7241
  force: z.boolean().optional()
6933
7242
  });
7243
+ function wantsSkillInstallWithoutToken(options) {
7244
+ return options.cursorSkills === true;
7245
+ }
7246
+ async function runInitSkillInstallSteps(options) {
7247
+ let skillStepOk = true;
7248
+ let skillSummary;
7249
+ try {
7250
+ const summary = await installSkills({
7251
+ target: options.global ? "global" : "project",
7252
+ force: options.force ?? false
7253
+ });
7254
+ console.log(`
7255
+ Skills installed to: ${summary.targetDir}/`);
7256
+ console.log(` installed: ${summary.installed.length}`);
7257
+ console.log(` overwritten: ${summary.overwritten.length}`);
7258
+ console.log(` skipped: ${summary.skipped.length}`);
7259
+ if (summary.skipped.length > 0) {
7260
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
7261
+ }
7262
+ skillSummary = {
7263
+ installed: summary.installed.length,
7264
+ overwritten: summary.overwritten.length,
7265
+ skipped: summary.skipped.length
7266
+ };
7267
+ } catch (skillError) {
7268
+ console.error(
7269
+ `
7270
+ Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
7271
+ );
7272
+ process.exitCode = 1;
7273
+ skillStepOk = false;
7274
+ }
7275
+ if (options.cursorSkills && skillStepOk) {
7276
+ try {
7277
+ const cSummary = await installCursorBundledSkills({
7278
+ force: options.force ?? false
7279
+ });
7280
+ console.log(`
7281
+ Cursor skills installed to: ${cSummary.targetDir}/`);
7282
+ console.log(` installed: ${cSummary.installed.length}`);
7283
+ console.log(` overwritten: ${cSummary.overwritten.length}`);
7284
+ console.log(` skipped: ${cSummary.skipped.length}`);
7285
+ if (cSummary.skipped.length > 0) {
7286
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
7287
+ }
7288
+ console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
7289
+ } catch (cursorError) {
7290
+ console.error(
7291
+ `
7292
+ Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
7293
+ );
7294
+ process.exitCode = 1;
7295
+ skillStepOk = false;
7296
+ }
7297
+ }
7298
+ return skillSummary !== void 0 ? { skillStepOk, skillSummary } : { skillStepOk };
7299
+ }
6934
7300
  function registerInit(cli2) {
6935
- 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("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
7301
+ cli2.command(
7302
+ "init",
7303
+ "Set up canicode with Figma API token (never paste a token into agent chat \u2014 use FIGMA_TOKEN=\u2026 or the interactive prompt)"
7304
+ ).option(
7305
+ "--token <token>",
7306
+ "Save Figma API token (use env/CLI only \u2014 not agent chat) and install Claude Code skills to .claude/skills/"
7307
+ ).option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/ (with `--token`, runs after Claude skills; without token, installs Claude skills + Cursor bundle)").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
6936
7308
  try {
6937
7309
  const parseResult = InitOptionsSchema.safeParse(rawOptions);
6938
7310
  if (!parseResult.success) {
@@ -6947,103 +7319,57 @@ ${msg}`);
6947
7319
  initAiready(options.token);
6948
7320
  console.log(` Config saved: ${getConfigPath()}`);
6949
7321
  console.log(` Reports will be saved to: ${getReportsDir()}/`);
6950
- let skillStepOk = true;
6951
- let skillSummary;
6952
- if (options.skills !== false) {
6953
- try {
6954
- const summary = await installSkills({
6955
- target: options.global ? "global" : "project",
6956
- force: options.force ?? false
6957
- });
6958
- console.log(`
6959
- Skills installed to: ${summary.targetDir}/`);
6960
- console.log(` installed: ${summary.installed.length}`);
6961
- console.log(` overwritten: ${summary.overwritten.length}`);
6962
- console.log(` skipped: ${summary.skipped.length}`);
6963
- if (summary.skipped.length > 0) {
6964
- console.log(` (Re-run with --force to overwrite skipped files.)`);
6965
- }
6966
- skillSummary = {
6967
- installed: summary.installed.length,
6968
- overwritten: summary.overwritten.length,
6969
- skipped: summary.skipped.length
6970
- };
6971
- } catch (skillError) {
6972
- console.error(
6973
- `
6974
- Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
6975
- );
6976
- process.exitCode = 1;
6977
- skillStepOk = false;
6978
- }
6979
- } else if (options.cursorSkills) {
6980
- try {
6981
- const summary = await installClaudeGotchasSkillOnly({
6982
- force: options.force ?? false
6983
- });
6984
- console.log(`
6985
- Gotchas store (Claude Code skills path) installed to: ${summary.targetDir}/`);
6986
- console.log(` installed: ${summary.installed.length}`);
6987
- console.log(` overwritten: ${summary.overwritten.length}`);
6988
- console.log(` skipped: ${summary.skipped.length}`);
6989
- skillSummary = {
6990
- installed: summary.installed.length,
6991
- overwritten: summary.overwritten.length,
6992
- skipped: summary.skipped.length
6993
- };
6994
- } catch (skillError) {
6995
- console.error(
6996
- `
6997
- Gotchas skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
6998
- );
6999
- process.exitCode = 1;
7000
- skillStepOk = false;
7001
- }
7002
- }
7003
- if (options.cursorSkills && skillStepOk) {
7004
- try {
7005
- const cSummary = await installCursorBundledSkills({
7006
- force: options.force ?? false
7007
- });
7008
- console.log(`
7009
- Cursor skills installed to: ${cSummary.targetDir}/`);
7010
- console.log(` installed: ${cSummary.installed.length}`);
7011
- console.log(` overwritten: ${cSummary.overwritten.length}`);
7012
- console.log(` skipped: ${cSummary.skipped.length}`);
7013
- if (cSummary.skipped.length > 0) {
7014
- console.log(` (Re-run with --force to overwrite skipped files.)`);
7015
- }
7016
- console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
7017
- } catch (cursorError) {
7018
- console.error(
7019
- `
7020
- Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
7021
- );
7022
- process.exitCode = 1;
7023
- skillStepOk = false;
7024
- }
7322
+ const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
7323
+ trackEvent(EVENTS.CLI_INIT, {
7324
+ skillsRequested: true,
7325
+ cursorSkillsRequested: options.cursorSkills === true,
7326
+ skillStepOk,
7327
+ target: options.global ? "global" : "project",
7328
+ force: options.force ?? false,
7329
+ ...skillSummary ?? {}
7330
+ });
7331
+ if (skillStepOk) {
7332
+ console.log(
7333
+ formatNextSteps({
7334
+ figmaMcpPresent: figmaMcpRegistered(),
7335
+ skillsInstalled: true,
7336
+ cursorSkillsInstalled: options.cursorSkills === true
7337
+ })
7338
+ );
7025
7339
  }
7340
+ return;
7341
+ }
7342
+ if (wantsSkillInstallWithoutToken(options)) {
7343
+ const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
7026
7344
  trackEvent(EVENTS.CLI_INIT, {
7027
- skillsRequested: options.skills !== false,
7345
+ skillsRequested: true,
7028
7346
  cursorSkillsRequested: options.cursorSkills === true,
7029
7347
  skillStepOk,
7030
7348
  target: options.global ? "global" : "project",
7031
7349
  force: options.force ?? false,
7350
+ skillOnlyInit: true,
7032
7351
  ...skillSummary ?? {}
7033
7352
  });
7034
7353
  if (skillStepOk) {
7035
7354
  console.log(
7036
7355
  formatNextSteps({
7037
7356
  figmaMcpPresent: figmaMcpRegistered(),
7038
- skillsInstalled: options.skills !== false,
7357
+ skillsInstalled: true,
7039
7358
  cursorSkillsInstalled: options.cursorSkills === true
7040
7359
  })
7041
7360
  );
7361
+ console.log(
7362
+ "\n Figma token not saved \u2014 run `canicode init --token \u2026` when you need REST analyze or MCP against live files."
7363
+ );
7042
7364
  }
7043
7365
  return;
7044
7366
  }
7045
7367
  console.log(`CANICODE SETUP
7046
7368
  `);
7369
+ console.log(
7370
+ ` Never paste your token into Claude/Cursor chat \u2014 use FIGMA_TOKEN=\u2026 npx canicode init or this prompt only.
7371
+ `
7372
+ );
7047
7373
  console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
7048
7374
  console.log(` Get token: figma.com > Settings > Personal access tokens
7049
7375
  `);
@@ -7051,8 +7377,7 @@ ${msg}`);
7051
7377
  console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
7052
7378
  console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
7053
7379
  console.log(` --global Install to ~/.claude/skills/ instead`);
7054
- console.log(` --no-skills Skip skill install (token only)`);
7055
- console.log(` --cursor-skills Also install Cursor copies of all three skills (.cursor/skills/); with --no-skills, still installs .claude gotcha store + Cursor bundle`);
7380
+ console.log(` --cursor-skills Install Claude skills under .claude/skills/ plus Cursor copies under .cursor/skills/ (no --token yet \u2014 add --token when ready for REST analyze)`);
7056
7381
  console.log(` --force Overwrite existing skill files without prompting
7057
7382
  `);
7058
7383
  console.log(`After setup:`);
@@ -7151,6 +7476,164 @@ function registerListRules(cli2) {
7151
7476
  }
7152
7477
  });
7153
7478
  }
7479
+ var StepFourReportSchema = z.object({
7480
+ /** ✅ — Strategy A property write (or A's auto-fix branch) succeeded. */
7481
+ resolved: z.number().int().min(0),
7482
+ /** 📝 — Strategy C wrote a Figma annotation (or A/B fell back to one). */
7483
+ annotated: z.number().int().min(0),
7484
+ /**
7485
+ * 🌐 — `applyWithInstanceFallback` propagated the write up to the
7486
+ * source COMPONENT definition (only counted when
7487
+ * `allowDefinitionWrite: true`).
7488
+ */
7489
+ definitionWritten: z.number().int().min(0),
7490
+ /**
7491
+ * ⏭️ — User said "skip", "n/a", or otherwise declined a per-question
7492
+ * confirmation (Strategy B opt-out, etc.).
7493
+ */
7494
+ skipped: z.number().int().min(0)
7495
+ });
7496
+ var ReanalyzeForTallySchema = z.object({
7497
+ /**
7498
+ * Total remaining issues from the re-analyze. Maps to the existing
7499
+ * `issueCount` (analyze JSON) / `questions.length` (gotcha-survey JSON)
7500
+ * field — both downstream channels populate it.
7501
+ */
7502
+ issueCount: z.number().int().min(0),
7503
+ /**
7504
+ * Issues the re-analyze flagged with `acknowledged: true` because they
7505
+ * matched a canicode-authored Figma annotation harvested in Step 5a.
7506
+ * From the analyze response's top-level `acknowledgedCount` (#371).
7507
+ */
7508
+ acknowledgedCount: z.number().int().min(0)
7509
+ });
7510
+ z.object({
7511
+ /** ✅ resolved (passthrough from `stepFourReport.resolved`). */
7512
+ X: z.number().int().min(0),
7513
+ /** 📝 annotated. */
7514
+ Y: z.number().int().min(0),
7515
+ /** 🌐 definition writes propagated. */
7516
+ Z: z.number().int().min(0),
7517
+ /** ⏭️ skipped. */
7518
+ W: z.number().int().min(0),
7519
+ /** `X + Y + Z + W` — questions the SKILL acted on in Step 4. */
7520
+ N: z.number().int().min(0),
7521
+ /** `reanalyzeResponse.issueCount` — total remaining after re-analyze. */
7522
+ V: z.number().int().min(0),
7523
+ /**
7524
+ * `reanalyzeResponse.acknowledgedCount` — the slice of `V` that carries
7525
+ * a canicode annotation (counted at half weight by the density score
7526
+ * per #371, but still surfaced as remaining).
7527
+ */
7528
+ V_ack: z.number().int().min(0),
7529
+ /** `V - V_ack` — issues with no annotation; the user's follow-up backlog. */
7530
+ V_open: z.number().int().min(0)
7531
+ });
7532
+
7533
+ // src/core/roundtrip/compute-roundtrip-tally.ts
7534
+ function computeRoundtripTally(args) {
7535
+ const { stepFourReport, reanalyzeResponse } = args;
7536
+ const { resolved, annotated, definitionWritten, skipped } = stepFourReport;
7537
+ const { issueCount, acknowledgedCount } = reanalyzeResponse;
7538
+ if (acknowledgedCount > issueCount) {
7539
+ throw new Error(
7540
+ `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`
7541
+ );
7542
+ }
7543
+ return {
7544
+ X: resolved,
7545
+ Y: annotated,
7546
+ Z: definitionWritten,
7547
+ W: skipped,
7548
+ N: resolved + annotated + definitionWritten + skipped,
7549
+ V: issueCount,
7550
+ V_ack: acknowledgedCount,
7551
+ V_open: issueCount - acknowledgedCount
7552
+ };
7553
+ }
7554
+
7555
+ // src/cli/commands/roundtrip-tally.ts
7556
+ var RoundtripTallyCliOptionsSchema = z.object({
7557
+ analyze: z.string().min(1),
7558
+ step4: z.string().min(1)
7559
+ });
7560
+ function parseJsonFile(label, path, raw) {
7561
+ try {
7562
+ return JSON.parse(raw);
7563
+ } catch (err) {
7564
+ throw new Error(
7565
+ `roundtrip-tally: ${label} is not valid JSON (${path})`,
7566
+ { cause: err }
7567
+ );
7568
+ }
7569
+ }
7570
+ async function readUtf8File(label, path) {
7571
+ try {
7572
+ return await readFile(path, "utf-8");
7573
+ } catch (err) {
7574
+ const detail = err instanceof Error ? err.message : String(err);
7575
+ throw new Error(`roundtrip-tally: cannot read ${label} (${path}): ${detail}`, {
7576
+ cause: err
7577
+ });
7578
+ }
7579
+ }
7580
+ async function computeRoundtripTallyFromSavedFiles(args) {
7581
+ const [analyzeRaw, step4Raw] = await Promise.all([
7582
+ readUtf8File("--analyze", args.analyzePath),
7583
+ readUtf8File("--step4", args.step4Path)
7584
+ ]);
7585
+ const analyzeParsed = parseJsonFile("--analyze", args.analyzePath, analyzeRaw);
7586
+ const step4Parsed = parseJsonFile("--step4", args.step4Path, step4Raw);
7587
+ const reanalyzeResult = ReanalyzeForTallySchema.safeParse(analyzeParsed);
7588
+ if (!reanalyzeResult.success) {
7589
+ const msg = reanalyzeResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7590
+ throw new Error(
7591
+ `roundtrip-tally: --analyze must include issueCount and acknowledgedCount (${args.analyzePath}): ${msg}`
7592
+ );
7593
+ }
7594
+ const stepFourResult = StepFourReportSchema.safeParse(step4Parsed);
7595
+ if (!stepFourResult.success) {
7596
+ const msg = stepFourResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7597
+ throw new Error(
7598
+ `roundtrip-tally: --step4 must match StepFourReport (${args.step4Path}): ${msg}`
7599
+ );
7600
+ }
7601
+ return computeRoundtripTally({
7602
+ stepFourReport: stepFourResult.data,
7603
+ reanalyzeResponse: reanalyzeResult.data
7604
+ });
7605
+ }
7606
+ function registerRoundtripTally(cli2) {
7607
+ cli2.command(
7608
+ "roundtrip-tally",
7609
+ "Print the Step 5 roundtrip tally from re-analyze JSON and Step 4 outcome counts"
7610
+ ).option("--analyze <path>", "Path to re-analyze JSON (`canicode analyze --json` output)").option("--step4 <path>", "Path to Step 4 structured counts (resolved / annotated / definitionWritten / skipped)").example(" canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4-report.json").action(async (rawOptions) => {
7611
+ const parsed = RoundtripTallyCliOptionsSchema.safeParse(rawOptions);
7612
+ if (!parsed.success) {
7613
+ const msg = parsed.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
7614
+ console.error(`
7615
+ roundtrip-tally requires --analyze and --step4:
7616
+ ${msg}`);
7617
+ process.exit(1);
7618
+ }
7619
+ try {
7620
+ const tally = await computeRoundtripTallyFromSavedFiles({
7621
+ analyzePath: parsed.data.analyze,
7622
+ step4Path: parsed.data.step4
7623
+ });
7624
+ console.log(JSON.stringify(tally, null, 2));
7625
+ trackEvent(EVENTS.ROUNDTRIP_TALLY);
7626
+ } catch (err) {
7627
+ if (err instanceof Error) {
7628
+ trackError(err, { command: "roundtrip-tally" });
7629
+ console.error(err.message);
7630
+ } else {
7631
+ console.error(err);
7632
+ }
7633
+ process.exit(1);
7634
+ }
7635
+ });
7636
+ }
7154
7637
 
7155
7638
  // src/cli/internal-commands.ts
7156
7639
  var INTERNAL_COMMANDS = [
@@ -8429,9 +8912,9 @@ function registerCalibrateEvaluate(cli2) {
8429
8912
  if (!existsSync(conversionPath)) {
8430
8913
  throw new Error(`Conversion file not found: ${conversionPath}`);
8431
8914
  }
8432
- const { readFile: readFile4 } = await import('fs/promises');
8433
- const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
8434
- const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
8915
+ const { readFile: readFile5 } = await import('fs/promises');
8916
+ const analysisData = JSON.parse(await readFile5(analysisPath, "utf-8"));
8917
+ const conversionData = JSON.parse(await readFile5(conversionPath, "utf-8"));
8435
8918
  let fixtureName;
8436
8919
  if (options.runDir) {
8437
8920
  const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
@@ -10289,6 +10772,7 @@ registerVisualCompare(cli);
10289
10772
  registerInit(cli);
10290
10773
  registerConfig(cli);
10291
10774
  registerListRules(cli);
10775
+ registerRoundtripTally(cli);
10292
10776
  registerCalibrateAnalyze(cli);
10293
10777
  registerCalibrateEvaluate(cli);
10294
10778
  registerCalibrateGapReport(cli);
@@ -10312,6 +10796,7 @@ cli.help((sections) => {
10312
10796
  section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
10313
10797
  }
10314
10798
  }
10799
+ sections.splice(1, 0, { body: ` ${pkg2.description}` });
10315
10800
  sections.push(
10316
10801
  {
10317
10802
  title: "\nSetup",
@@ -10319,10 +10804,7 @@ cli.help((sections) => {
10319
10804
  },
10320
10805
  {
10321
10806
  title: "\nData source",
10322
- body: [
10323
- ` --api Load via Figma REST API (needs FIGMA_TOKEN)`,
10324
- ` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
10325
- ].join("\n")
10807
+ body: ` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
10326
10808
  },
10327
10809
  {
10328
10810
  title: "\nCustomization",
@@ -10331,10 +10813,10 @@ cli.help((sections) => {
10331
10813
  {
10332
10814
  title: "\nExamples",
10333
10815
  body: [
10334
- ` $ canicode analyze "https://www.figma.com/design/..." --api`,
10335
10816
  ` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
10336
10817
  ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
10337
- ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`
10818
+ ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`,
10819
+ ` $ canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4.json`
10338
10820
  ].join("\n")
10339
10821
  },
10340
10822
  {