canicode 0.11.1 → 0.11.3

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/README.md CHANGED
@@ -152,7 +152,7 @@ Drops three skills into `./.claude/skills/`:
152
152
 
153
153
  The skills shell out to `npx canicode …` for analyze / gotcha-survey when the canicode MCP server is not installed — both paths produce the same JSON shape. The Figma MCP server is still required for the apply step (Step 4 in `/canicode-roundtrip`); see the prereq note above.
154
154
 
155
- Flags: `--global` installs into `~/.claude/skills/` instead. `--no-skills` skips skill install (token only). `--force` overwrites existing skill files without prompting. Run `canicode docs setup` for the full setup guide.
155
+ Flags: `--global` installs into `~/.claude/skills/` instead. `--cursor-skills` also installs Cursor copies under `.cursor/skills/`. `--force` overwrites existing skill files without prompting. Run `canicode docs setup` for the full setup guide.
156
156
 
157
157
  </details>
158
158
 
@@ -182,7 +182,7 @@ For Cursor / Claude Desktop config, see [`docs/CUSTOMIZATION.md`](docs/CUSTOMIZA
182
182
  npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
183
183
  ```
184
184
 
185
- Setup: `npx canicode init --token figd_xxxxxxxxxxxxx` saves the token (and installs the Claude Code skills as a bonus — pass `--no-skills` to skip).
185
+ Setup: `npx canicode init --token figd_xxxxxxxxxxxxx` saves the token and installs the Claude Code skills into `./.claude/skills/`.
186
186
 
187
187
  **Figma API Rate Limits** — Rate limits depend on **where the file lives**, not just your plan.
188
188
 
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';
@@ -1548,7 +1548,11 @@ CANICODE SETUP GUIDE
1548
1548
 
1549
1549
  Setup:
1550
1550
  canicode init --token figd_xxxxxxxxxxxxx
1551
- (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)
1552
1556
 
1553
1557
  Use:
1554
1558
  canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
@@ -1558,6 +1562,7 @@ CANICODE SETUP GUIDE
1558
1562
  --preset strict|relaxed|dev-friendly|ai-ready
1559
1563
  --config ./my-config.json
1560
1564
  --no-open Don't open report in browser
1565
+ --api No-op for Figma URLs (REST always); same flag as gotcha-survey (#461)
1561
1566
 
1562
1567
  Output:
1563
1568
  ~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
@@ -1579,7 +1584,7 @@ CANICODE SETUP GUIDE
1579
1584
 
1580
1585
  Flags:
1581
1586
  --global Install to ~/.claude/skills/ instead of ./.claude/skills/
1582
- --no-skills Skip skill install (token only \u2014 legacy behavior)
1587
+ --cursor-skills Also install Cursor copies (see \xA73)
1583
1588
  --force Overwrite existing skill files without prompting
1584
1589
 
1585
1590
  Use (in Claude Code):
@@ -1604,8 +1609,8 @@ CANICODE SETUP GUIDE
1604
1609
 
1605
1610
  Flags:
1606
1611
  --cursor-skills Install Cursor copies of all three skills into .cursor/skills/
1607
- --no-skills Skip Claude Code skills (with --cursor-skills, still installs
1608
- the Cursor bundle plus the shared gotchas answer file)
1612
+ (with --token, runs after full Claude skill install; without token,
1613
+ installs Claude skills under .claude/skills/ first, then Cursor copies)
1609
1614
  --force Overwrite existing skill files without prompting
1610
1615
 
1611
1616
  Use (in Cursor Agent chat):
@@ -1613,6 +1618,14 @@ CANICODE SETUP GUIDE
1613
1618
  @canicode-gotchas <figma-url> Run a gotcha survey
1614
1619
  @canicode-roundtrip <figma-url> Analyze, fix gotchas in Figma, re-analyze
1615
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
+
1616
1629
  See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
1617
1630
  writes; analyze-only works without it).
1618
1631
 
@@ -4232,9 +4245,11 @@ function computeApplyContext(violation, instanceContext) {
4232
4245
  }
4233
4246
 
4234
4247
  // package.json
4235
- var version2 = "0.11.1";
4248
+ var version2 = "0.11.3";
4236
4249
 
4237
4250
  // src/core/engine/scoring.ts
4251
+ var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
4252
+ var DEFAULT_CODEGEN_READY_MIN_GRADE = "A";
4238
4253
  function computeTotalScorePerCategory(configs) {
4239
4254
  const totals = Object.fromEntries(
4240
4255
  CATEGORIES.map((c) => [c, 0])
@@ -4261,8 +4276,9 @@ function calculateGrade(percentage) {
4261
4276
  if (percentage >= 50) return "D";
4262
4277
  return "F";
4263
4278
  }
4264
- function isReadyForCodeGen(grade) {
4265
- return grade === "S" || grade === "A+" || grade === "A";
4279
+ function isReadyForCodeGen(grade, minGrade) {
4280
+ const threshold = minGrade ?? DEFAULT_CODEGEN_READY_MIN_GRADE;
4281
+ return GRADE_ORDER.indexOf(grade) <= GRADE_ORDER.indexOf(threshold);
4266
4282
  }
4267
4283
  function clamp(value, min, max) {
4268
4284
  return Math.max(min, Math.min(max, value));
@@ -4455,7 +4471,7 @@ function buildResultJson(fileName, result, scores, options) {
4455
4471
  scope: result.scope,
4456
4472
  issueCount: result.issues.length,
4457
4473
  acknowledgedCount: scores.summary.acknowledgedCount,
4458
- isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
4474
+ isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade, options?.codegenReadyMinGrade),
4459
4475
  blockingIssueCount: scores.summary.blocking,
4460
4476
  scores: {
4461
4477
  overall: scores.overall,
@@ -4492,6 +4508,7 @@ var ConfigFileSchema = z.object({
4492
4508
  excludeNodeTypes: z.array(z.string()).optional(),
4493
4509
  excludeNodeNames: z.array(z.string()).optional(),
4494
4510
  gridBase: z.number().int().positive().optional(),
4511
+ codegenReadyMinGrade: z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]).optional(),
4495
4512
  rules: z.record(z.string(), RuleOverrideSchema).superRefine((rules, ctx) => {
4496
4513
  const unknown = Object.keys(rules).filter((id) => !VALID_RULE_IDS.has(id));
4497
4514
  if (unknown.length > 0) {
@@ -5711,16 +5728,21 @@ var AnalyzeOptionsSchema = z.object({
5711
5728
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
5712
5729
  output: z.string().optional(),
5713
5730
  token: z.string().optional(),
5731
+ /** Accepted for CLI parity; Figma URL loads always use the REST API today (#461). */
5714
5732
  api: z.boolean().optional(),
5715
5733
  screenshot: z.boolean().optional(),
5716
5734
  config: z.string().optional(),
5717
5735
  noOpen: z.boolean().optional(),
5718
5736
  json: z.boolean().optional(),
5719
5737
  acknowledgments: z.string().optional(),
5720
- scope: z.enum(["page", "component"]).optional()
5738
+ scope: z.enum(["page", "component"]).optional(),
5739
+ readyMinGrade: z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]).optional()
5721
5740
  });
5722
5741
  function registerAnalyze(cli2) {
5723
- 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 / 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 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) => {
5742
+ 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(
5743
+ "--api",
5744
+ "No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `gotcha-survey` (#461)."
5745
+ ).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.").option("--ready-min-grade <grade>", "Minimum grade for code-gen readiness (S | A+ | A | B+ | B | C+ | C | D | F). Overrides configPath codegenReadyMinGrade. Default: A").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) => {
5724
5746
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5725
5747
  if (!parseResult.success) {
5726
5748
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -5730,6 +5752,7 @@ ${msg}`);
5730
5752
  process.exit(1);
5731
5753
  }
5732
5754
  const options = parseResult.data;
5755
+ void options.api;
5733
5756
  const analysisStart = Date.now();
5734
5757
  trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" });
5735
5758
  const log = options.json ? console.error.bind(console) : console.log.bind(console);
@@ -5782,13 +5805,16 @@ Analyzing: ${file.name}`);
5782
5805
  let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
5783
5806
  let excludeNodeNames;
5784
5807
  let excludeNodeTypes;
5808
+ let codegenReadyMinGrade;
5785
5809
  if (options.config) {
5786
5810
  const configFile = await loadConfigFile(options.config);
5787
5811
  configs = mergeConfigs(configs, configFile);
5788
5812
  excludeNodeNames = configFile.excludeNodeNames;
5789
5813
  excludeNodeTypes = configFile.excludeNodeTypes;
5814
+ codegenReadyMinGrade = configFile.codegenReadyMinGrade;
5790
5815
  log(`Config loaded: ${options.config}`);
5791
5816
  }
5817
+ const effectiveMinGrade = options.readyMinGrade ?? codegenReadyMinGrade;
5792
5818
  let acknowledgments;
5793
5819
  if (options.acknowledgments) {
5794
5820
  const ackPath = resolve(options.acknowledgments);
@@ -5814,7 +5840,7 @@ Analyzing: ${file.name}`);
5814
5840
  log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
5815
5841
  const scores = calculateScores(result, configs);
5816
5842
  if (options.json) {
5817
- console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input) }), null, 2));
5843
+ console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input), ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {} }), null, 2));
5818
5844
  if (scores.overall.grade === "F") {
5819
5845
  process.exitCode = 1;
5820
5846
  }
@@ -6006,7 +6032,13 @@ var BATCHABLE_RULE_IDS = [
6006
6032
  "no-auto-layout",
6007
6033
  "fixed-size-in-auto-layout"
6008
6034
  ];
6035
+ var OPT_IN_BATCHABLE_RULE_IDS = [
6036
+ "missing-prototype"
6037
+ ];
6009
6038
  var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
6039
+ var OPT_IN_BATCHABLE_SET = new Set(
6040
+ OPT_IN_BATCHABLE_RULE_IDS
6041
+ );
6010
6042
  var NO_SOURCE_SENTINEL = "_no-source";
6011
6043
  function groupAndBatchSurveyQuestions(questions) {
6012
6044
  if (questions.length === 0) {
@@ -6047,20 +6079,25 @@ function sourceComponentKey(question) {
6047
6079
  }
6048
6080
  function pushIntoBatch(group, question) {
6049
6081
  const sceneWeight = Math.max(question.replicas ?? 1, 1);
6050
- const isBatchable = BATCHABLE_SET.has(question.ruleId);
6082
+ const batchMode = resolveBatchMode(question.ruleId);
6051
6083
  const last = group.batches.at(-1);
6052
- if (last !== void 0 && last.ruleId === question.ruleId && isBatchable && last.batchable) {
6084
+ if (last !== void 0 && last.ruleId === question.ruleId && batchMode !== "none" && last.batchMode === batchMode) {
6053
6085
  last.questions.push(question);
6054
6086
  last.totalScenes += sceneWeight;
6055
6087
  return;
6056
6088
  }
6057
6089
  group.batches.push({
6058
6090
  ruleId: question.ruleId,
6059
- batchable: isBatchable,
6091
+ batchMode,
6060
6092
  questions: [question],
6061
6093
  totalScenes: sceneWeight
6062
6094
  });
6063
6095
  }
6096
+ function resolveBatchMode(ruleId) {
6097
+ if (BATCHABLE_SET.has(ruleId)) return "safe";
6098
+ if (OPT_IN_BATCHABLE_SET.has(ruleId)) return "opt-in";
6099
+ return "none";
6100
+ }
6064
6101
 
6065
6102
  // src/core/gotcha/survey-generator.ts
6066
6103
  var NODE_PATH_SEPARATOR = " > ";
@@ -6079,12 +6116,18 @@ function generateGotchaSurvey(result, scores, options = {}) {
6079
6116
  const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
6080
6117
  const questions = deduplicateBySourceComponent(mapped);
6081
6118
  const groupedQuestions = groupAndBatchSurveyQuestions(questions);
6119
+ const PROPAGATION_CANDIDATE_THRESHOLD = 3;
6120
+ const propagationCandidates = questions.filter(
6121
+ (q) => q.isInstanceChild
6122
+ ).length;
6123
+ const suggestedDefaultApply = propagationCandidates >= PROPAGATION_CANDIDATE_THRESHOLD;
6082
6124
  return {
6083
6125
  designGrade: grade,
6084
- isReadyForCodeGen: isReadyForCodeGen(grade),
6126
+ isReadyForCodeGen: isReadyForCodeGen(grade, options.codegenReadyMinGrade),
6085
6127
  questions,
6086
6128
  groupedQuestions,
6087
- designKey: options.designKey ?? ""
6129
+ designKey: options.designKey ?? "",
6130
+ suggestedDefaultApply
6088
6131
  };
6089
6132
  }
6090
6133
  function deduplicateSiblingIssues(issues) {
@@ -6233,26 +6276,36 @@ function findNodeById2(node, id) {
6233
6276
  var GotchaSurveyOptionsSchema = z.object({
6234
6277
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
6235
6278
  token: z.string().optional(),
6279
+ /** Accepted for CLI parity with `analyze`; Figma URL loads always use REST (#461). */
6280
+ api: z.boolean().optional(),
6236
6281
  config: z.string().optional(),
6237
6282
  targetNodeId: z.string().optional(),
6238
6283
  json: z.boolean().optional(),
6239
- scope: z.enum(["page", "component"]).optional()
6284
+ scope: z.enum(["page", "component"]).optional(),
6285
+ readyMinGrade: z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]).optional()
6240
6286
  });
6241
6287
  async function runGotchaSurvey(input, options) {
6288
+ void options.api;
6242
6289
  const { file, nodeId } = await loadFile(input, options.token);
6243
6290
  const effectiveNodeId = options.targetNodeId ?? nodeId;
6244
6291
  let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
6292
+ let codegenReadyMinGrade;
6245
6293
  if (options.config) {
6246
6294
  const configFile = await loadConfigFile(options.config);
6247
6295
  configs = mergeConfigs(configs, configFile);
6296
+ codegenReadyMinGrade = configFile.codegenReadyMinGrade;
6248
6297
  }
6298
+ const effectiveMinGrade = options.readyMinGrade ?? codegenReadyMinGrade;
6249
6299
  const result = analyzeFile(file, {
6250
6300
  configs,
6251
6301
  ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
6252
6302
  ...options.scope ? { scope: options.scope } : {}
6253
6303
  });
6254
6304
  const scores = calculateScores(result, configs);
6255
- return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
6305
+ return generateGotchaSurvey(result, scores, {
6306
+ designKey: computeDesignKey(input),
6307
+ ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {}
6308
+ });
6256
6309
  }
6257
6310
  function formatHumanSummary(survey) {
6258
6311
  const lines = [
@@ -6267,7 +6320,10 @@ function formatHumanSummary(survey) {
6267
6320
  return lines.join("\n");
6268
6321
  }
6269
6322
  function registerGotchaSurvey(cli2) {
6270
- 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) => {
6323
+ 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(
6324
+ "--api",
6325
+ "No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `analyze` (#461)."
6326
+ ).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)").option("--ready-min-grade <grade>", "Minimum grade for code-gen readiness (S | A+ | A | B+ | B | C+ | C | D | F). Overrides configPath codegenReadyMinGrade. Default: A").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
6271
6327
  const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
6272
6328
  if (!parseResult.success) {
6273
6329
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -6386,13 +6442,17 @@ var GotchaSurveyQuestionSchema = z.object({
6386
6442
  var SurveyQuestionBatchSchema = z.object({
6387
6443
  ruleId: z.string(),
6388
6444
  /**
6389
- * `true` when every member shares an answer-shape uniformly applicable to
6390
- * all of them (e.g. one `min-width` value covers all FILL children).
6391
- * The SKILL renders one shared prompt for `batchable: true` batches with
6392
- * `questions.length >= 2`; everything else falls through to the
6393
- * single-question template.
6445
+ * Rendering mode for this batch (see `BatchMode` in
6446
+ * `src/core/gotcha/group-and-batch-questions.ts` the authoritative
6447
+ * whitelists `BATCHABLE_RULE_IDS` and `OPT_IN_BATCHABLE_RULE_IDS` live
6448
+ * there):
6449
+ * - `"safe"` — one answer uniformly applies to every member (#369).
6450
+ * - `"opt-in"` — one shared answer is a suggested default; the user may
6451
+ * reply `split` for per-node override (#426).
6452
+ * - `"none"` — single-member batch, renders the standard per-question
6453
+ * template.
6394
6454
  */
6395
- batchable: z.boolean(),
6455
+ batchMode: z.enum(["safe", "opt-in", "none"]),
6396
6456
  questions: z.array(GotchaSurveyQuestionSchema),
6397
6457
  /**
6398
6458
  * Sum of `max(question.replicas, 1)` across `questions`. Counts the
@@ -6426,7 +6486,21 @@ var GotchaSurveySchema = z.object({
6426
6486
  * this directly when upserting the per-design section, so the SKILL.md
6427
6487
  * prose no longer parses URLs (per ADR-016).
6428
6488
  */
6429
- designKey: z.string()
6489
+ designKey: z.string(),
6490
+ /**
6491
+ * #428 — threshold hint for the `allowDefinitionWrite` picker in the
6492
+ * `canicode-roundtrip` skill. `true` when `propagationCandidates >= 3`
6493
+ * (i.e. three or more questions target instance children that could
6494
+ * benefit from definition-level writes). When `false`, the skill silently
6495
+ * uses the annotation default (ADR-012) without surfacing the picker —
6496
+ * the opt-in flow is over-engineered for tiny surveys.
6497
+ *
6498
+ * Computed server-side from `questions` so the skill doesn't have to
6499
+ * count `isInstanceChild` manually; the skill may still override this
6500
+ * hint when it has additional context (e.g. all candidates are
6501
+ * read-only per probe result).
6502
+ */
6503
+ suggestedDefaultApply: z.boolean()
6430
6504
  });
6431
6505
  var AnswersMapSchema = z.record(
6432
6506
  z.string(),
@@ -6908,52 +6982,6 @@ function defaultSourceDir() {
6908
6982
  function defaultCursorBundleRoot() {
6909
6983
  return fileURLToPath(new URL("../../skills/cursor", import.meta.url));
6910
6984
  }
6911
- async function copySkillTree(skillName, srcSkillDir, destSkillDir, force) {
6912
- if (!existsSync(srcSkillDir)) {
6913
- throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
6914
- }
6915
- mkdirSync(destSkillDir, { recursive: true });
6916
- const ops = [];
6917
- const files = listFilesRecursive(srcSkillDir);
6918
- for (const relPath of files) {
6919
- const src = join(srcSkillDir, relPath);
6920
- const dest = join(destSkillDir, relPath);
6921
- mkdirSync(dirname(dest), { recursive: true });
6922
- const label = join(skillName, relPath);
6923
- let action;
6924
- if (!existsSync(dest)) {
6925
- action = "install";
6926
- } else if (force) {
6927
- action = "force-overwrite";
6928
- } else {
6929
- action = "needs-decision";
6930
- }
6931
- ops.push({ src, dest, label, action });
6932
- }
6933
- const candidates = ops.filter((op) => op.action === "needs-decision");
6934
- const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
6935
- const installed = [];
6936
- const overwritten = [];
6937
- const skipped = [];
6938
- for (const op of ops) {
6939
- if (op.action === "install") {
6940
- copyFileSync(op.src, op.dest);
6941
- installed.push(op.label);
6942
- } else if (op.action === "force-overwrite") {
6943
- copyFileSync(op.src, op.dest);
6944
- overwritten.push(op.label);
6945
- } else {
6946
- const decision = decisions.get(op.label) ?? "skip";
6947
- if (decision === "overwrite") {
6948
- copyFileSync(op.src, op.dest);
6949
- overwritten.push(op.label);
6950
- } else {
6951
- skipped.push(op.label);
6952
- }
6953
- }
6954
- }
6955
- return { installed, overwritten, skipped };
6956
- }
6957
6985
  async function copyMultipleSkillTrees(entries, force) {
6958
6986
  const ops = [];
6959
6987
  for (const { skillName, srcSkillDir, destSkillDir } of entries) {
@@ -7064,35 +7092,6 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
7064
7092
  }
7065
7093
  return summary;
7066
7094
  }
7067
- var InstallClaudeGotchasOnlySchema = z.object({
7068
- force: z.boolean(),
7069
- cwd: z.string().optional(),
7070
- sourceDir: z.string().optional()
7071
- });
7072
- async function installClaudeGotchasSkillOnly(rawOptions) {
7073
- const options = InstallClaudeGotchasOnlySchema.parse(rawOptions);
7074
- const sourceDir = options.sourceDir ?? defaultSourceDir();
7075
- const skillName = "canicode-gotchas";
7076
- const srcSkillDir = join(sourceDir, skillName);
7077
- const cwd = options.cwd ?? process.cwd();
7078
- const targetDir = join(cwd, ".claude", "skills");
7079
- const destSkillDir = join(targetDir, skillName);
7080
- if (!existsSync(sourceDir)) {
7081
- throw new Error(
7082
- `Bundled skills directory not found: ${sourceDir}
7083
- If you are developing canicode, run 'pnpm build' first.
7084
- If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
7085
- );
7086
- }
7087
- mkdirSync(targetDir, { recursive: true });
7088
- const part = await copySkillTree(skillName, srcSkillDir, destSkillDir, options.force);
7089
- return {
7090
- installed: part.installed,
7091
- overwritten: part.overwritten,
7092
- skipped: part.skipped,
7093
- targetDir
7094
- };
7095
- }
7096
7095
  var InstallCursorBundledSchema = z.object({
7097
7096
  force: z.boolean(),
7098
7097
  cwd: z.string().optional(),
@@ -7252,14 +7251,67 @@ function formatNextSteps(opts) {
7252
7251
  var InitOptionsSchema = z.object({
7253
7252
  token: z.string().optional(),
7254
7253
  global: z.boolean().optional(),
7255
- // Declared positively as `--skills`; mri's built-in `--no-` prefix handling
7256
- // still maps `--no-skills` to `skills: false`. Declaring the option
7257
- // positively avoids cac's `(default: true)` artifact on negated flags.
7258
- skills: z.boolean().optional(),
7259
7254
  /** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
7260
7255
  cursorSkills: z.boolean().optional(),
7261
7256
  force: z.boolean().optional()
7262
7257
  });
7258
+ function wantsSkillInstallWithoutToken(options) {
7259
+ return options.cursorSkills === true;
7260
+ }
7261
+ async function runInitSkillInstallSteps(options) {
7262
+ let skillStepOk = true;
7263
+ let skillSummary;
7264
+ try {
7265
+ const summary = await installSkills({
7266
+ target: options.global ? "global" : "project",
7267
+ force: options.force ?? false
7268
+ });
7269
+ console.log(`
7270
+ Skills installed to: ${summary.targetDir}/`);
7271
+ console.log(` installed: ${summary.installed.length}`);
7272
+ console.log(` overwritten: ${summary.overwritten.length}`);
7273
+ console.log(` skipped: ${summary.skipped.length}`);
7274
+ if (summary.skipped.length > 0) {
7275
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
7276
+ }
7277
+ skillSummary = {
7278
+ installed: summary.installed.length,
7279
+ overwritten: summary.overwritten.length,
7280
+ skipped: summary.skipped.length
7281
+ };
7282
+ } catch (skillError) {
7283
+ console.error(
7284
+ `
7285
+ Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
7286
+ );
7287
+ process.exitCode = 1;
7288
+ skillStepOk = false;
7289
+ }
7290
+ if (options.cursorSkills && skillStepOk) {
7291
+ try {
7292
+ const cSummary = await installCursorBundledSkills({
7293
+ force: options.force ?? false
7294
+ });
7295
+ console.log(`
7296
+ Cursor skills installed to: ${cSummary.targetDir}/`);
7297
+ console.log(` installed: ${cSummary.installed.length}`);
7298
+ console.log(` overwritten: ${cSummary.overwritten.length}`);
7299
+ console.log(` skipped: ${cSummary.skipped.length}`);
7300
+ if (cSummary.skipped.length > 0) {
7301
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
7302
+ }
7303
+ console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
7304
+ } catch (cursorError) {
7305
+ console.error(
7306
+ `
7307
+ Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
7308
+ );
7309
+ process.exitCode = 1;
7310
+ skillStepOk = false;
7311
+ }
7312
+ }
7313
+ return skillSummary !== void 0 ? { skillStepOk, skillSummary } : { skillStepOk };
7314
+ }
7263
7315
  function registerInit(cli2) {
7264
7316
  cli2.command(
7265
7317
  "init",
@@ -7267,7 +7319,7 @@ function registerInit(cli2) {
7267
7319
  ).option(
7268
7320
  "--token <token>",
7269
7321
  "Save Figma API token (use env/CLI only \u2014 not agent chat) and install Claude Code skills to .claude/skills/"
7270
- ).option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--skills", "Install Claude Code skills into .claude/skills/ (default: on \u2014 pass --no-skills to opt out)").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) => {
7322
+ ).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) => {
7271
7323
  try {
7272
7324
  const parseResult = InitOptionsSchema.safeParse(rawOptions);
7273
7325
  if (!parseResult.success) {
@@ -7282,98 +7334,48 @@ ${msg}`);
7282
7334
  initAiready(options.token);
7283
7335
  console.log(` Config saved: ${getConfigPath()}`);
7284
7336
  console.log(` Reports will be saved to: ${getReportsDir()}/`);
7285
- let skillStepOk = true;
7286
- let skillSummary;
7287
- if (options.skills !== false) {
7288
- try {
7289
- const summary = await installSkills({
7290
- target: options.global ? "global" : "project",
7291
- force: options.force ?? false
7292
- });
7293
- console.log(`
7294
- Skills installed to: ${summary.targetDir}/`);
7295
- console.log(` installed: ${summary.installed.length}`);
7296
- console.log(` overwritten: ${summary.overwritten.length}`);
7297
- console.log(` skipped: ${summary.skipped.length}`);
7298
- if (summary.skipped.length > 0) {
7299
- console.log(` (Re-run with --force to overwrite skipped files.)`);
7300
- }
7301
- skillSummary = {
7302
- installed: summary.installed.length,
7303
- overwritten: summary.overwritten.length,
7304
- skipped: summary.skipped.length
7305
- };
7306
- } catch (skillError) {
7307
- console.error(
7308
- `
7309
- Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
7310
- );
7311
- process.exitCode = 1;
7312
- skillStepOk = false;
7313
- }
7314
- } else if (options.cursorSkills) {
7315
- try {
7316
- const summary = await installClaudeGotchasSkillOnly({
7317
- force: options.force ?? false
7318
- });
7319
- console.log(`
7320
- Gotchas store (Claude Code skills path) installed to: ${summary.targetDir}/`);
7321
- console.log(` installed: ${summary.installed.length}`);
7322
- console.log(` overwritten: ${summary.overwritten.length}`);
7323
- console.log(` skipped: ${summary.skipped.length}`);
7324
- skillSummary = {
7325
- installed: summary.installed.length,
7326
- overwritten: summary.overwritten.length,
7327
- skipped: summary.skipped.length
7328
- };
7329
- } catch (skillError) {
7330
- console.error(
7331
- `
7332
- Gotchas skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
7333
- );
7334
- process.exitCode = 1;
7335
- skillStepOk = false;
7336
- }
7337
- }
7338
- if (options.cursorSkills && skillStepOk) {
7339
- try {
7340
- const cSummary = await installCursorBundledSkills({
7341
- force: options.force ?? false
7342
- });
7343
- console.log(`
7344
- Cursor skills installed to: ${cSummary.targetDir}/`);
7345
- console.log(` installed: ${cSummary.installed.length}`);
7346
- console.log(` overwritten: ${cSummary.overwritten.length}`);
7347
- console.log(` skipped: ${cSummary.skipped.length}`);
7348
- if (cSummary.skipped.length > 0) {
7349
- console.log(` (Re-run with --force to overwrite skipped files.)`);
7350
- }
7351
- console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
7352
- } catch (cursorError) {
7353
- console.error(
7354
- `
7355
- Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
7356
- );
7357
- process.exitCode = 1;
7358
- skillStepOk = false;
7359
- }
7337
+ const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
7338
+ trackEvent(EVENTS.CLI_INIT, {
7339
+ skillsRequested: true,
7340
+ cursorSkillsRequested: options.cursorSkills === true,
7341
+ skillStepOk,
7342
+ target: options.global ? "global" : "project",
7343
+ force: options.force ?? false,
7344
+ ...skillSummary ?? {}
7345
+ });
7346
+ if (skillStepOk) {
7347
+ console.log(
7348
+ formatNextSteps({
7349
+ figmaMcpPresent: figmaMcpRegistered(),
7350
+ skillsInstalled: true,
7351
+ cursorSkillsInstalled: options.cursorSkills === true
7352
+ })
7353
+ );
7360
7354
  }
7355
+ return;
7356
+ }
7357
+ if (wantsSkillInstallWithoutToken(options)) {
7358
+ const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
7361
7359
  trackEvent(EVENTS.CLI_INIT, {
7362
- skillsRequested: options.skills !== false,
7360
+ skillsRequested: true,
7363
7361
  cursorSkillsRequested: options.cursorSkills === true,
7364
7362
  skillStepOk,
7365
7363
  target: options.global ? "global" : "project",
7366
7364
  force: options.force ?? false,
7365
+ skillOnlyInit: true,
7367
7366
  ...skillSummary ?? {}
7368
7367
  });
7369
7368
  if (skillStepOk) {
7370
7369
  console.log(
7371
7370
  formatNextSteps({
7372
7371
  figmaMcpPresent: figmaMcpRegistered(),
7373
- skillsInstalled: options.skills !== false,
7372
+ skillsInstalled: true,
7374
7373
  cursorSkillsInstalled: options.cursorSkills === true
7375
7374
  })
7376
7375
  );
7376
+ console.log(
7377
+ "\n Figma token not saved \u2014 run `canicode init --token \u2026` when you need REST analyze or MCP against live files."
7378
+ );
7377
7379
  }
7378
7380
  return;
7379
7381
  }
@@ -7390,8 +7392,7 @@ ${msg}`);
7390
7392
  console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
7391
7393
  console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
7392
7394
  console.log(` --global Install to ~/.claude/skills/ instead`);
7393
- console.log(` --no-skills Skip skill install (token only)`);
7394
- console.log(` --cursor-skills Also install Cursor copies of all three skills (.cursor/skills/); with --no-skills, still installs .claude gotcha store + Cursor bundle`);
7395
+ 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)`);
7395
7396
  console.log(` --force Overwrite existing skill files without prompting
7396
7397
  `);
7397
7398
  console.log(`After setup:`);
@@ -10818,10 +10819,7 @@ cli.help((sections) => {
10818
10819
  },
10819
10820
  {
10820
10821
  title: "\nData source",
10821
- body: [
10822
- ` --api Load via Figma REST API (needs FIGMA_TOKEN)`,
10823
- ` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
10824
- ].join("\n")
10822
+ body: ` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
10825
10823
  },
10826
10824
  {
10827
10825
  title: "\nCustomization",
@@ -10830,7 +10828,6 @@ cli.help((sections) => {
10830
10828
  {
10831
10829
  title: "\nExamples",
10832
10830
  body: [
10833
- ` $ canicode analyze "https://www.figma.com/design/..." --api`,
10834
10831
  ` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
10835
10832
  ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
10836
10833
  ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`,