spec-layer 0.4.0 → 0.6.0

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.
Files changed (3) hide show
  1. package/README.md +168 -17
  2. package/dist/cli.js +1591 -124
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -262,7 +262,7 @@ var require_sha256 = __commonJS({
262
262
  }
263
263
  notString = true;
264
264
  }
265
- var code, index = 0, i, length = message.length, blocks2 = this.blocks;
265
+ var code2, index = 0, i, length = message.length, blocks2 = this.blocks;
266
266
  while (index < length) {
267
267
  if (this.hashed) {
268
268
  this.hashed = false;
@@ -275,22 +275,22 @@ var require_sha256 = __commonJS({
275
275
  }
276
276
  } else {
277
277
  for (i = this.start; index < length && i < 64; ++index) {
278
- code = message.charCodeAt(index);
279
- if (code < 128) {
280
- blocks2[i >>> 2] |= code << SHIFT[i++ & 3];
281
- } else if (code < 2048) {
282
- blocks2[i >>> 2] |= (192 | code >>> 6) << SHIFT[i++ & 3];
283
- blocks2[i >>> 2] |= (128 | code & 63) << SHIFT[i++ & 3];
284
- } else if (code < 55296 || code >= 57344) {
285
- blocks2[i >>> 2] |= (224 | code >>> 12) << SHIFT[i++ & 3];
286
- blocks2[i >>> 2] |= (128 | code >>> 6 & 63) << SHIFT[i++ & 3];
287
- blocks2[i >>> 2] |= (128 | code & 63) << SHIFT[i++ & 3];
278
+ code2 = message.charCodeAt(index);
279
+ if (code2 < 128) {
280
+ blocks2[i >>> 2] |= code2 << SHIFT[i++ & 3];
281
+ } else if (code2 < 2048) {
282
+ blocks2[i >>> 2] |= (192 | code2 >>> 6) << SHIFT[i++ & 3];
283
+ blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
284
+ } else if (code2 < 55296 || code2 >= 57344) {
285
+ blocks2[i >>> 2] |= (224 | code2 >>> 12) << SHIFT[i++ & 3];
286
+ blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
287
+ blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
288
288
  } else {
289
- code = 65536 + ((code & 1023) << 10 | message.charCodeAt(++index) & 1023);
290
- blocks2[i >>> 2] |= (240 | code >>> 18) << SHIFT[i++ & 3];
291
- blocks2[i >>> 2] |= (128 | code >>> 12 & 63) << SHIFT[i++ & 3];
292
- blocks2[i >>> 2] |= (128 | code >>> 6 & 63) << SHIFT[i++ & 3];
293
- blocks2[i >>> 2] |= (128 | code & 63) << SHIFT[i++ & 3];
289
+ code2 = 65536 + ((code2 & 1023) << 10 | message.charCodeAt(++index) & 1023);
290
+ blocks2[i >>> 2] |= (240 | code2 >>> 18) << SHIFT[i++ & 3];
291
+ blocks2[i >>> 2] |= (128 | code2 >>> 12 & 63) << SHIFT[i++ & 3];
292
+ blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
293
+ blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
294
294
  }
295
295
  }
296
296
  }
@@ -472,24 +472,24 @@ var require_sha256 = __commonJS({
472
472
  function HmacSha256(key, is224, sharedMemory) {
473
473
  var i, type = typeof key;
474
474
  if (type === "string") {
475
- var bytes = [], length = key.length, index = 0, code;
475
+ var bytes = [], length = key.length, index = 0, code2;
476
476
  for (i = 0; i < length; ++i) {
477
- code = key.charCodeAt(i);
478
- if (code < 128) {
479
- bytes[index++] = code;
480
- } else if (code < 2048) {
481
- bytes[index++] = 192 | code >>> 6;
482
- bytes[index++] = 128 | code & 63;
483
- } else if (code < 55296 || code >= 57344) {
484
- bytes[index++] = 224 | code >>> 12;
485
- bytes[index++] = 128 | code >>> 6 & 63;
486
- bytes[index++] = 128 | code & 63;
477
+ code2 = key.charCodeAt(i);
478
+ if (code2 < 128) {
479
+ bytes[index++] = code2;
480
+ } else if (code2 < 2048) {
481
+ bytes[index++] = 192 | code2 >>> 6;
482
+ bytes[index++] = 128 | code2 & 63;
483
+ } else if (code2 < 55296 || code2 >= 57344) {
484
+ bytes[index++] = 224 | code2 >>> 12;
485
+ bytes[index++] = 128 | code2 >>> 6 & 63;
486
+ bytes[index++] = 128 | code2 & 63;
487
487
  } else {
488
- code = 65536 + ((code & 1023) << 10 | key.charCodeAt(++i) & 1023);
489
- bytes[index++] = 240 | code >>> 18;
490
- bytes[index++] = 128 | code >>> 12 & 63;
491
- bytes[index++] = 128 | code >>> 6 & 63;
492
- bytes[index++] = 128 | code & 63;
488
+ code2 = 65536 + ((code2 & 1023) << 10 | key.charCodeAt(++i) & 1023);
489
+ bytes[index++] = 240 | code2 >>> 18;
490
+ bytes[index++] = 128 | code2 >>> 12 & 63;
491
+ bytes[index++] = 128 | code2 >>> 6 & 63;
492
+ bytes[index++] = 128 | code2 & 63;
493
493
  }
494
494
  }
495
495
  key = bytes;
@@ -559,7 +559,8 @@ var require_sha256 = __commonJS({
559
559
  import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
- import { join as join5 } from "node:path";
562
+ import { existsSync as existsSync8 } from "node:fs";
563
+ import { join as join7, resolve as resolve4 } from "node:path";
563
564
 
564
565
  // ../extractor/src/statesMatrix.ts
565
566
  var STATE_ORDER = [
@@ -642,10 +643,10 @@ var DEFAULT_SEVERITY = {
642
643
  EXPORT_SCOPED: "info"
643
644
  };
644
645
  var compareCodeUnits = (a, b) => a < b ? -1 : a > b ? 1 : 0;
645
- function diagnostic(code, fields) {
646
+ function diagnostic(code2, fields) {
646
647
  return {
647
- code,
648
- severity: DEFAULT_SEVERITY[code],
648
+ code: code2,
649
+ severity: DEFAULT_SEVERITY[code2],
649
650
  entity_id: fields.entity_id,
650
651
  ...fields.mode_id !== void 0 ? { mode_id: fields.mode_id } : {},
651
652
  message: fields.message,
@@ -1611,15 +1612,15 @@ function styleMember(p, property, scopes, path, name) {
1611
1612
  });
1612
1613
  return null;
1613
1614
  }
1614
- const converted = dtcgLiteral(property.resolved, scopes, p.options.values);
1615
- if ("omit" in converted) {
1616
- if (converted.omit === "unit_not_expressible") {
1615
+ const converted2 = dtcgLiteral(property.resolved, scopes, p.options.values);
1616
+ if ("omit" in converted2) {
1617
+ if (converted2.omit === "unit_not_expressible") {
1617
1618
  reportOnce(p, {
1618
1619
  code: "unit_not_expressible",
1619
1620
  severity: "info",
1620
1621
  path,
1621
1622
  message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
1622
- details: { property: name, ...converted.details }
1623
+ details: { property: name, ...converted2.details }
1623
1624
  });
1624
1625
  const d = property.resolved;
1625
1626
  return { extension: { value: d.number, unit: d.unit } };
@@ -1629,11 +1630,11 @@ function styleMember(p, property, scopes, path, name) {
1629
1630
  severity: "warning",
1630
1631
  path,
1631
1632
  message: `The ${name} property has a type DTCG cannot state and was omitted.`,
1632
- details: { property: name, ...converted.details }
1633
+ details: { property: name, ...converted2.details }
1633
1634
  });
1634
1635
  return null;
1635
1636
  }
1636
- return { value: converted.$value };
1637
+ return { value: converted2.$value };
1637
1638
  }
1638
1639
  var TYPOGRAPHY_MEMBERS = [
1639
1640
  ["font_family", "fontFamily", []],
@@ -1733,8 +1734,8 @@ function effectLeaf(p, style, path) {
1733
1734
  }
1734
1735
  const raw = effect[field];
1735
1736
  if (raw === void 0) continue;
1736
- const converted = dtcgLiteral(raw, [], p.options.values);
1737
- if (!("omit" in converted)) shadow[name] = converted.$value;
1737
+ const converted2 = dtcgLiteral(raw, [], p.options.values);
1738
+ if (!("omit" in converted2)) shadow[name] = converted2.$value;
1738
1739
  }
1739
1740
  shadow.inset = effect.type === "inner_shadow";
1740
1741
  shadows.push(shadow);
@@ -2000,7 +2001,7 @@ function tokenLeaf(p, token, collection, modeId) {
2000
2001
  }
2001
2002
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2002
2003
  }
2003
- const converted = projectedLiteral(p, token, value.value, (override) => {
2004
+ const converted2 = projectedLiteral(p, token, value.value, (override) => {
2004
2005
  reportOnce(p, {
2005
2006
  code: "unit_override_conflicts_with_scope",
2006
2007
  severity: "warning",
@@ -2009,18 +2010,18 @@ function tokenLeaf(p, token, collection, modeId) {
2009
2010
  details: { id: token.id, override, scopes: [...token.scopes] }
2010
2011
  });
2011
2012
  });
2012
- if ("omit" in converted) {
2013
+ if ("omit" in converted2) {
2013
2014
  reportOnce(p, {
2014
- code: converted.omit,
2015
+ code: converted2.omit,
2015
2016
  severity: "warning",
2016
2017
  path,
2017
2018
  mode,
2018
- message: converted.omit === "type_not_expressible" ? `DTCG has no ${String(converted.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted.details.unit)} value was omitted.`,
2019
- details: { id: token.id, ...converted.details }
2019
+ message: converted2.omit === "type_not_expressible" ? `DTCG has no ${String(converted2.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted2.details.unit)} value was omitted.`,
2020
+ details: { id: token.id, ...converted2.details }
2020
2021
  });
2021
2022
  return null;
2022
2023
  }
2023
- return { $type: converted.$type, $value: converted.$value, ...description };
2024
+ return { $type: converted2.$type, $value: converted2.$value, ...description };
2024
2025
  }
2025
2026
  function dtcgExportFiles(out) {
2026
2027
  const text = (v) => `${JSON.stringify(v, null, 2)}
@@ -2033,16 +2034,475 @@ function dtcgExportFiles(out) {
2033
2034
  return files;
2034
2035
  }
2035
2036
 
2037
+ // ../extractor/src/v5/outputs/naming.ts
2038
+ var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
2039
+ function splitWords(segment) {
2040
+ return segment.replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
2041
+ }
2042
+ function pathWords(path) {
2043
+ return path.split(".").flatMap((seg) => {
2044
+ const words = splitWords(seg);
2045
+ return words.length > 0 ? words : ["_"];
2046
+ });
2047
+ }
2048
+ function joinWords(words, nameCase) {
2049
+ const lower = words.map((w) => w.toLowerCase());
2050
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
2051
+ switch (nameCase) {
2052
+ case "kebab":
2053
+ return lower.join("-");
2054
+ case "snake":
2055
+ return lower.join("_");
2056
+ case "constant":
2057
+ return lower.join("_").toUpperCase();
2058
+ case "camel":
2059
+ return lower.map((w, i) => i === 0 ? w : cap(w)).join("");
2060
+ case "pascal":
2061
+ return lower.map(cap).join("");
2062
+ default: {
2063
+ const exhaustive = nameCase;
2064
+ return exhaustive;
2065
+ }
2066
+ }
2067
+ }
2068
+ function deriveName(path, nameCase) {
2069
+ return joinWords(pathWords(path), nameCase);
2070
+ }
2071
+ function sortReport(entries) {
2072
+ return [...entries].sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
2073
+ }
2074
+ function resolveNames(paths, meta, rules) {
2075
+ const report2 = [];
2076
+ const candidates = /* @__PURE__ */ new Map();
2077
+ for (const path of [...paths].sort(compareCodeUnits)) {
2078
+ let name = null;
2079
+ let source = "derived";
2080
+ const declared = rules.codeSyntaxKey ? meta[path]?.code_syntax?.[rules.codeSyntaxKey] : void 0;
2081
+ if (declared !== void 0) {
2082
+ name = rules.acceptDeclared(declared);
2083
+ if (name !== null) {
2084
+ source = "code_syntax";
2085
+ } else {
2086
+ report2.push({
2087
+ code: "code_syntax_not_usable",
2088
+ severity: "info",
2089
+ path,
2090
+ message: `The declared ${rules.codeSyntaxKey} identifier "${declared}" is not a name this format can use; the name was derived instead.`,
2091
+ details: { declared, platform: rules.codeSyntaxKey ?? "" }
2092
+ });
2093
+ }
2094
+ }
2095
+ if (name === null) name = rules.affix(deriveName(path, rules.nameCase));
2096
+ const list = candidates.get(name) ?? [];
2097
+ list.push({ path, source });
2098
+ candidates.set(name, list);
2099
+ }
2100
+ const names = /* @__PURE__ */ new Map();
2101
+ const map = {};
2102
+ for (const [name, list] of candidates) {
2103
+ if (list.length > 1) {
2104
+ const collided = list.map((c) => c.path).sort(compareCodeUnits);
2105
+ for (const c of list) {
2106
+ report2.push({
2107
+ code: "name_collision",
2108
+ severity: "error",
2109
+ path: c.path,
2110
+ message: `${list.length} tokens would share the name ${name}; all were omitted.`,
2111
+ details: { name, paths: collided }
2112
+ });
2113
+ }
2114
+ continue;
2115
+ }
2116
+ names.set(list[0].path, name);
2117
+ map[list[0].path] = { name, source: list[0].source };
2118
+ }
2119
+ const sortedMap = Object.fromEntries(Object.entries(map).sort(([a], [b]) => compareCodeUnits(a, b)));
2120
+ return { names, map: sortedMap, report: sortReport(report2) };
2121
+ }
2122
+
2123
+ // ../extractor/src/v5/outputs/css.ts
2124
+ var CSS_DEFAULTS = {
2125
+ case: "kebab",
2126
+ root: ":root",
2127
+ modeSelector: '[data-theme="{mode}"]'
2128
+ };
2129
+ var CSS_HEADER_PREFIX = "/* Generated by spec-layer";
2130
+ var EXT = "com.spec-layer";
2131
+ var CUSTOM_PROPERTY = /^--[A-Za-z0-9_-]+$/;
2132
+ var BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
2133
+ function acceptCssDeclared(declared) {
2134
+ if (CUSTOM_PROPERTY.test(declared)) return declared;
2135
+ if (BARE_IDENT.test(declared)) return `--${declared}`;
2136
+ return null;
2137
+ }
2138
+ var asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
2139
+ function collectLeaves(tree, prefix, out) {
2140
+ const node = asRecord(tree);
2141
+ if (!node) return;
2142
+ if ("$value" in node) {
2143
+ const ours = asRecord(asRecord(node.$extensions)?.[EXT]);
2144
+ out.push({
2145
+ path: prefix.join("."),
2146
+ type: typeof node.$type === "string" ? node.$type : "",
2147
+ value: node.$value,
2148
+ ext: ours
2149
+ });
2150
+ return;
2151
+ }
2152
+ for (const key of Object.keys(node)) {
2153
+ if (key.startsWith("$")) continue;
2154
+ collectLeaves(node[key], [...prefix, key], out);
2155
+ }
2156
+ }
2157
+ var unpointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
2158
+ var refFile = (src) => {
2159
+ const r = asRecord(src);
2160
+ return r && typeof r.$ref === "string" ? r.$ref : null;
2161
+ };
2162
+ function sourcesOf(resolver) {
2163
+ const out = [];
2164
+ for (const { $ref } of resolver.resolutionOrder) {
2165
+ const set = /^#\/sets\/(.+)$/.exec($ref);
2166
+ const mod = /^#\/modifiers\/(.+)$/.exec($ref);
2167
+ if (set) {
2168
+ const label = unpointer(set[1]);
2169
+ for (const src of resolver.sets[label]?.sources ?? []) {
2170
+ const file = refFile(src);
2171
+ if (file) out.push({ collection: label, mode: null, file, isDefault: true });
2172
+ }
2173
+ } else if (mod) {
2174
+ const label = unpointer(mod[1]);
2175
+ const m = resolver.modifiers[label];
2176
+ if (!m) continue;
2177
+ for (const [context, srcs] of Object.entries(m.contexts)) {
2178
+ for (const src of srcs) {
2179
+ const file = refFile(src);
2180
+ if (file) out.push({ collection: label, mode: context, file, isDefault: context === m.default });
2181
+ }
2182
+ }
2183
+ }
2184
+ }
2185
+ return out;
2186
+ }
2187
+ var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
2188
+ var collectionSlug = (file) => file.split(".")[0];
2189
+ var REF = /^\{(.+)\}$/;
2190
+ function hexChannels(hex) {
2191
+ const at = (i) => parseInt(hex.slice(i, i + 2), 16);
2192
+ return [at(1), at(3), at(5)];
2193
+ }
2194
+ var quoteFamily = (f) => `"${f.replace(/["\\]/g, "\\$&")}"`;
2195
+ function report(ctx, entry2) {
2196
+ ctx.report.push({ ...entry2, path: ctx.path, ...ctx.mode !== void 0 ? { mode: ctx.mode } : {} });
2197
+ }
2198
+ function cssValue(ctx, type, value, property) {
2199
+ const member = property !== void 0 ? { property } : {};
2200
+ if (typeof value === "string") {
2201
+ const ref = REF.exec(value);
2202
+ if (ref) {
2203
+ const name = ctx.alive.has(ref[1]) ? ctx.names.get(ref[1]) : void 0;
2204
+ if (name !== void 0) return `var(${name})`;
2205
+ report(ctx, {
2206
+ code: "reference_target_omitted",
2207
+ severity: "warning",
2208
+ message: `References ${ref[1]}, which this output does not emit; the value was omitted.`,
2209
+ details: { target: ref[1], ...member }
2210
+ });
2211
+ return null;
2212
+ }
2213
+ }
2214
+ switch (type) {
2215
+ case "color": {
2216
+ if (typeof value === "string") return value;
2217
+ const c = asRecord(value);
2218
+ if (c && typeof c.hex === "string" && typeof c.alpha === "number") {
2219
+ if (c.alpha === 1) return c.hex;
2220
+ const [r, g, b] = hexChannels(c.hex);
2221
+ return `rgb(${r} ${g} ${b} / ${c.alpha})`;
2222
+ }
2223
+ break;
2224
+ }
2225
+ case "dimension":
2226
+ case "duration": {
2227
+ if (typeof value === "string") return value;
2228
+ const d = asRecord(value);
2229
+ if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
2230
+ break;
2231
+ }
2232
+ case "number":
2233
+ case "fontWeight":
2234
+ if (typeof value === "number") return String(value);
2235
+ break;
2236
+ case "cubicBezier":
2237
+ if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
2238
+ return `cubic-bezier(${value.join(", ")})`;
2239
+ }
2240
+ break;
2241
+ case "fontFamily":
2242
+ if (typeof value === "string") return quoteFamily(value);
2243
+ if (Array.isArray(value) && value.every((f) => typeof f === "string")) {
2244
+ return value.map(quoteFamily).join(", ");
2245
+ }
2246
+ break;
2247
+ default:
2248
+ break;
2249
+ }
2250
+ report(ctx, {
2251
+ code: "not_expressible",
2252
+ severity: "warning",
2253
+ message: `CSS has no form for this ${type || "untyped"} value; it was omitted.`,
2254
+ details: { type, value, ...member }
2255
+ });
2256
+ return null;
2257
+ }
2258
+ var TEXT_TRANSFORM = { original: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" };
2259
+ var TEXT_DECORATION = { none: "none", underline: "underline", strikethrough: "line-through" };
2260
+ var TYPOGRAPHY_MEMBERS2 = [
2261
+ ["fontFamily", "fontFamily"],
2262
+ ["fontSize", "dimension"],
2263
+ ["fontWeight", "fontWeight"],
2264
+ ["lineHeight", "number"],
2265
+ ["letterSpacing", "dimension"]
2266
+ ];
2267
+ var TEXT_MEMBERS = [
2268
+ ["textTransform", "textCase", TEXT_TRANSFORM],
2269
+ ["textDecoration", "textDecoration", TEXT_DECORATION]
2270
+ ];
2271
+ function typographyMemberKeys(value, ext) {
2272
+ const keys = [];
2273
+ for (const [key] of TYPOGRAPHY_MEMBERS2) {
2274
+ if (key in value) keys.push(key);
2275
+ }
2276
+ for (const key of ["lineHeight", "letterSpacing"]) {
2277
+ if (key in value) continue;
2278
+ const d = asRecord(ext[key]);
2279
+ if (d && typeof d.value === "number" && typeof d.unit === "string") keys.push(key);
2280
+ }
2281
+ for (const [key, extKey] of TEXT_MEMBERS) {
2282
+ if (typeof ext[extKey] === "string") keys.push(key);
2283
+ }
2284
+ return keys;
2285
+ }
2286
+ function converted(ctx, property, from, to) {
2287
+ report(ctx, {
2288
+ code: "value_converted",
2289
+ severity: "info",
2290
+ message: `${property} was restated from ${String(from.value)}${String(from.unit)} as ${to}.`,
2291
+ details: { property, from, to }
2292
+ });
2293
+ }
2294
+ function extensionDimension(ctx, ext, key, name, percentTo) {
2295
+ const d = asRecord(ext[key]);
2296
+ if (!d || typeof d.value !== "number" || typeof d.unit !== "string") return null;
2297
+ if (d.unit === "%") {
2298
+ const to = percentTo(canonicalNumber(d.value / 100));
2299
+ converted(ctx, key, d, to);
2300
+ return `${name}: ${to};`;
2301
+ }
2302
+ return `${name}: ${d.value}${d.unit};`;
2303
+ }
2304
+ function typographyDecls(ctx, leaf, names) {
2305
+ const decls = [];
2306
+ const declaredPaths = [];
2307
+ const value = asRecord(leaf.value) ?? {};
2308
+ const ext = leaf.ext ?? {};
2309
+ const nameFor = (key) => names.get(`${leaf.path}.${key}`);
2310
+ for (const [key, type] of TYPOGRAPHY_MEMBERS2) {
2311
+ if (!(key in value)) continue;
2312
+ const name = nameFor(key);
2313
+ if (name === void 0) continue;
2314
+ const css = cssValue(ctx, type, value[key], key);
2315
+ if (css !== null) {
2316
+ decls.push(`${name}: ${css};`);
2317
+ declaredPaths.push(`${leaf.path}.${key}`);
2318
+ }
2319
+ }
2320
+ if (!("lineHeight" in value)) {
2321
+ const name = nameFor("lineHeight");
2322
+ if (name !== void 0) {
2323
+ const d = extensionDimension(ctx, ext, "lineHeight", name, (n) => String(n));
2324
+ if (d) {
2325
+ decls.push(d);
2326
+ declaredPaths.push(`${leaf.path}.lineHeight`);
2327
+ }
2328
+ }
2329
+ }
2330
+ if (!("letterSpacing" in value)) {
2331
+ const name = nameFor("letterSpacing");
2332
+ if (name !== void 0) {
2333
+ const d = extensionDimension(ctx, ext, "letterSpacing", name, (n) => `${n}em`);
2334
+ if (d) {
2335
+ decls.push(d);
2336
+ declaredPaths.push(`${leaf.path}.letterSpacing`);
2337
+ }
2338
+ }
2339
+ }
2340
+ for (const [key, extKey, table] of TEXT_MEMBERS) {
2341
+ const raw = ext[extKey];
2342
+ if (typeof raw !== "string") continue;
2343
+ const name = nameFor(key);
2344
+ if (name === void 0) continue;
2345
+ const css = table[raw];
2346
+ if (css !== void 0) {
2347
+ decls.push(`${name}: ${css};`);
2348
+ declaredPaths.push(`${leaf.path}.${key}`);
2349
+ } else {
2350
+ report(ctx, {
2351
+ code: "not_expressible",
2352
+ severity: "info",
2353
+ message: `CSS text properties have no form for ${extKey} "${raw}"; it was omitted.`,
2354
+ details: { property: extKey, value: raw }
2355
+ });
2356
+ }
2357
+ }
2358
+ return { decls, declaredPaths };
2359
+ }
2360
+ var SHADOW_MEMBERS = [
2361
+ ["offsetX", "dimension"],
2362
+ ["offsetY", "dimension"],
2363
+ ["blur", "dimension"],
2364
+ ["spread", "dimension"],
2365
+ ["color", "color"]
2366
+ ];
2367
+ function shadowDecl(ctx, leaf, name) {
2368
+ const layers = Array.isArray(leaf.value) ? leaf.value : [];
2369
+ const omit = (reason, message) => {
2370
+ report(ctx, { code: "not_expressible", severity: "warning", message, details: { reason } });
2371
+ return null;
2372
+ };
2373
+ if (layers.length === 0) return omit("no_visible_shadow", "The style has no visible shadow, so no box-shadow was written.");
2374
+ const parts = [];
2375
+ for (const layer of layers) {
2376
+ const l = asRecord(layer);
2377
+ if (!l) return omit("layer_not_an_object", "A shadow layer is not an object; the style was omitted.");
2378
+ const members = [];
2379
+ for (const [key, type] of SHADOW_MEMBERS) {
2380
+ if (!(key in l)) return omit(`missing_${key}`, `A shadow layer has no ${key}, which box-shadow needs; the style was omitted.`);
2381
+ const css = cssValue(ctx, type, l[key], key);
2382
+ if (css === null) return null;
2383
+ members.push(css);
2384
+ }
2385
+ parts.push(`${l.inset === true ? "inset " : ""}${members.join(" ")}`);
2386
+ }
2387
+ return `${name}: ${parts.join(", ")};`;
2388
+ }
2389
+ var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
2390
+ function headerText(header, nameCase) {
2391
+ return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
2392
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
2393
+ }
2394
+ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2395
+ const entries = [];
2396
+ const declared = /* @__PURE__ */ new Set();
2397
+ const blocks = /* @__PURE__ */ new Map();
2398
+ for (const s of sources) {
2399
+ const perCollection = modes?.[s.collection];
2400
+ const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2401
+ const decls = [];
2402
+ for (const leaf of leavesByFile.get(s.file) ?? []) {
2403
+ const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2404
+ if (leaf.type === "typography") {
2405
+ const t = typographyDecls(ctx, leaf, names);
2406
+ decls.push(...t.decls);
2407
+ for (const p of t.declaredPaths) declared.add(p);
2408
+ } else {
2409
+ const name = names.get(leaf.path);
2410
+ if (name === void 0) continue;
2411
+ if (leaf.type === "shadow") {
2412
+ const d = shadowDecl(ctx, leaf, name);
2413
+ if (d !== null) {
2414
+ decls.push(d);
2415
+ declared.add(leaf.path);
2416
+ }
2417
+ } else {
2418
+ const v = cssValue(ctx, leaf.type, leaf.value);
2419
+ if (v !== null) {
2420
+ decls.push(`${name}: ${v};`);
2421
+ declared.add(leaf.path);
2422
+ }
2423
+ }
2424
+ }
2425
+ }
2426
+ if (decls.length === 0) continue;
2427
+ const lines = blocks.get(selector) ?? [];
2428
+ lines.push(` /* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`, ...decls.map((d) => ` ${d}`));
2429
+ blocks.set(selector, lines);
2430
+ }
2431
+ return { blocks, entries, declared };
2432
+ }
2433
+ function cssOutput(exp, header, options = {}) {
2434
+ const nameCase = options.case ?? CSS_DEFAULTS.case;
2435
+ const root = options.root ?? CSS_DEFAULTS.root;
2436
+ const template = options.modeSelector ?? CSS_DEFAULTS.modeSelector;
2437
+ const sources = sourcesOf(exp.resolver);
2438
+ const leavesByFile = /* @__PURE__ */ new Map();
2439
+ for (const s of sources) {
2440
+ if (!leavesByFile.has(s.file)) {
2441
+ const out = [];
2442
+ collectLeaves(exp.files[s.file] ?? {}, [], out);
2443
+ leavesByFile.set(s.file, out);
2444
+ }
2445
+ }
2446
+ const candidatePaths = /* @__PURE__ */ new Set();
2447
+ for (const leaves of leavesByFile.values()) {
2448
+ for (const leaf of leaves) {
2449
+ if (leaf.type === "typography") {
2450
+ const value = asRecord(leaf.value) ?? {};
2451
+ const ext = leaf.ext ?? {};
2452
+ for (const key of typographyMemberKeys(value, ext)) candidatePaths.add(`${leaf.path}.${key}`);
2453
+ } else {
2454
+ candidatePaths.add(leaf.path);
2455
+ }
2456
+ }
2457
+ }
2458
+ const resolved2 = resolveNames([...candidatePaths], exp.meta, {
2459
+ codeSyntaxKey: "WEB",
2460
+ acceptDeclared: acceptCssDeclared,
2461
+ affix: (body2) => `--${body2}`,
2462
+ nameCase
2463
+ });
2464
+ const names = resolved2.names;
2465
+ const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
2466
+ let alive = new Set(names.keys());
2467
+ let pass = emit(alive);
2468
+ for (; ; ) {
2469
+ if (pass.declared.size === alive.size) break;
2470
+ alive = pass.declared;
2471
+ pass = emit(alive);
2472
+ }
2473
+ const map = {};
2474
+ for (const [path, entry2] of Object.entries(resolved2.map)) {
2475
+ if (alive.has(path)) map[path] = entry2;
2476
+ }
2477
+ const entries = [...resolved2.report, ...pass.entries];
2478
+ const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
2479
+ if (shared.length > 1) {
2480
+ for (const collection of shared) {
2481
+ entries.push({
2482
+ code: "mode_selector_shared",
2483
+ severity: "warning",
2484
+ path: collection,
2485
+ message: `${shared.length} collections with modes share the selector template ${template}; one attribute cannot carry both axes. Declare a selector per collection under outputs[].modes.`,
2486
+ details: { modifiers: shared, selector: template }
2487
+ });
2488
+ }
2489
+ }
2490
+ const body = [...pass.blocks].flatMap(([selector, lines]) => [`${selector} {`, ...lines, "}", ""]);
2491
+ const text = `${[headerText(header, nameCase), "", ...body].join("\n").trimEnd()}
2492
+ `;
2493
+ return { text, map, report: sortReport(entries) };
2494
+ }
2495
+
2036
2496
  // ../extractor/src/v5/componentContext.ts
2037
2497
  var import_js_sha2563 = __toESM(require_sha256(), 1);
2038
2498
 
2039
2499
  // ../extractor/src/libraryBundle.ts
2040
2500
  var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
2041
2501
  var LibraryBundleError = class extends Error {
2042
- constructor(code, message) {
2502
+ constructor(code2, message) {
2043
2503
  super(message);
2044
2504
  this.name = "LibraryBundleError";
2045
- this.code = code;
2505
+ this.code = code2;
2046
2506
  }
2047
2507
  };
2048
2508
  var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -2101,6 +2561,9 @@ function parseLibraryBundle(input) {
2101
2561
  };
2102
2562
  }
2103
2563
 
2564
+ // ../extractor/src/libraryBundleHash.ts
2565
+ var import_js_sha2564 = __toESM(require_sha256(), 1);
2566
+
2104
2567
  // src/bundle.ts
2105
2568
  function parseBundle(raw) {
2106
2569
  try {
@@ -2121,8 +2584,8 @@ function parseBundle(raw) {
2121
2584
  }
2122
2585
 
2123
2586
  // src/config.ts
2124
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
2125
- import { join as join2 } from "node:path";
2587
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
2588
+ import { join as join3 } from "node:path";
2126
2589
 
2127
2590
  // src/credentials.ts
2128
2591
  import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
@@ -2155,6 +2618,268 @@ function writeCredentials(cwd, stored) {
2155
2618
  return { replaced };
2156
2619
  }
2157
2620
 
2621
+ // src/detect.ts
2622
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2623
+ import { join as join2 } from "node:path";
2624
+ var CODE_SYNTAX_KEY = {
2625
+ web: "WEB",
2626
+ ios: "iOS",
2627
+ android: "ANDROID",
2628
+ flutter: null
2629
+ };
2630
+ var PLATFORMS = ["web", "ios", "android", "flutter"];
2631
+ var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2632
+ var uniq = (xs) => [...new Set(xs)];
2633
+ function readPackageJson(cwd) {
2634
+ const path = join2(cwd, "package.json");
2635
+ if (!existsSync2(path)) return null;
2636
+ let parsed;
2637
+ try {
2638
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
2639
+ } catch {
2640
+ return null;
2641
+ }
2642
+ if (typeof parsed !== "object" || parsed === null) return null;
2643
+ const record = parsed;
2644
+ const deps = {};
2645
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2646
+ const block = record[field];
2647
+ if (typeof block !== "object" || block === null) continue;
2648
+ for (const [name, range] of Object.entries(block)) {
2649
+ if (typeof range === "string") deps[name] = range;
2650
+ }
2651
+ }
2652
+ return { deps };
2653
+ }
2654
+ function majorOf(range) {
2655
+ const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
2656
+ return m ? Number(m[1]) : null;
2657
+ }
2658
+ var DEP_SIGNALS = [
2659
+ { dep: "react", platform: "web", framework: "react" },
2660
+ { dep: "next", platform: "web", framework: "next" },
2661
+ { dep: "vue", platform: "web", framework: "vue" },
2662
+ { dep: "nuxt", platform: "web", framework: "nuxt" },
2663
+ { dep: "svelte", platform: "web", framework: "svelte" },
2664
+ { dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
2665
+ { dep: "@angular/core", platform: "web", framework: "angular" },
2666
+ { dep: "solid-js", platform: "web", framework: "solid" },
2667
+ { dep: "lit", platform: "web", framework: "lit" },
2668
+ { dep: "astro", platform: "web", framework: "astro" },
2669
+ { dep: "react-native", platform: "ios", framework: "react-native" },
2670
+ { dep: "expo", platform: "ios", framework: "expo" },
2671
+ { dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
2672
+ { dep: "styled-components", platform: "web", framework: "styled-components" },
2673
+ { dep: "@emotion/react", platform: "web", framework: "emotion" },
2674
+ { dep: "sass", platform: "web", framework: "sass" },
2675
+ { dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
2676
+ { dep: "@stitches/react", platform: "web", framework: "stitches" },
2677
+ { dep: "@pandacss/dev", platform: "web", framework: "panda" },
2678
+ { dep: "style-dictionary", tokenTool: "style-dictionary" },
2679
+ { dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
2680
+ { dep: "typescript", language: "typescript" }
2681
+ ];
2682
+ var FILE_SIGNALS = [
2683
+ { test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
2684
+ { test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
2685
+ { test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
2686
+ { test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
2687
+ { test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
2688
+ { test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
2689
+ { test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
2690
+ { test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
2691
+ { test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
2692
+ { test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
2693
+ { test: (n) => n === "go.mod", signal: "Go module", language: "go" },
2694
+ { test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
2695
+ { test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
2696
+ { test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
2697
+ { test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
2698
+ { test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
2699
+ { test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
2700
+ { test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
2701
+ { test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
2702
+ { test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
2703
+ { test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
2704
+ { test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
2705
+ { test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
2706
+ { test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
2707
+ ];
2708
+ function detectRepo(cwd) {
2709
+ const platforms = [];
2710
+ const languages = [];
2711
+ const frameworks = [];
2712
+ const tokenTools = [];
2713
+ const agents = [];
2714
+ const evidence = [];
2715
+ let styleDictionaryMajor = null;
2716
+ let names = [];
2717
+ try {
2718
+ names = readdirSync(cwd).sort();
2719
+ } catch {
2720
+ names = [];
2721
+ }
2722
+ for (const name of names) {
2723
+ for (const rule of FILE_SIGNALS) {
2724
+ if (!rule.test(name)) continue;
2725
+ evidence.push({ signal: rule.signal, file: name });
2726
+ if (rule.platform) platforms.push(rule.platform);
2727
+ if (rule.language) languages.push(rule.language);
2728
+ if (rule.framework) frameworks.push(rule.framework);
2729
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2730
+ if (rule.agent) agents.push(rule.agent);
2731
+ }
2732
+ }
2733
+ if (existsSync2(join2(cwd, ".github", "copilot-instructions.md")) || existsSync2(join2(cwd, ".github", "instructions"))) {
2734
+ evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
2735
+ agents.push("copilot");
2736
+ }
2737
+ const pkg = readPackageJson(cwd);
2738
+ if (pkg) {
2739
+ for (const rule of DEP_SIGNALS) {
2740
+ const range = pkg.deps[rule.dep];
2741
+ if (range === void 0) continue;
2742
+ evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
2743
+ if (rule.platform) platforms.push(rule.platform);
2744
+ if (rule.framework) frameworks.push(rule.framework);
2745
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2746
+ if (rule.language) languages.push(rule.language);
2747
+ if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
2748
+ }
2749
+ if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
2750
+ }
2751
+ const order = (p) => PLATFORMS.indexOf(p);
2752
+ const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
2753
+ return {
2754
+ platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
2755
+ languages: uniq(languages).sort(),
2756
+ frameworks: uniq(frameworks).sort(),
2757
+ tokenTools: uniq(tokenTools).sort(),
2758
+ agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
2759
+ styleDictionaryMajor,
2760
+ evidence
2761
+ };
2762
+ }
2763
+ function isPlatform(value) {
2764
+ return PLATFORMS.includes(value);
2765
+ }
2766
+ function isAgentHost(value) {
2767
+ return AGENT_HOSTS.includes(value);
2768
+ }
2769
+
2770
+ // src/outputs.ts
2771
+ import {
2772
+ existsSync as existsSync3,
2773
+ mkdirSync,
2774
+ readFileSync as readFileSync3,
2775
+ renameSync,
2776
+ rmSync,
2777
+ writeFileSync as writeFileSync2
2778
+ } from "node:fs";
2779
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2780
+ var FORMATS = [
2781
+ { platform: "web", format: "css", defaultPath: "spec-layer/tokens.css", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2782
+ ];
2783
+ var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
2784
+ var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
2785
+ function defaultOutputs(platforms) {
2786
+ return FORMATS.filter((f) => f.defaultPath !== null && platforms.includes(f.platform)).map((f) => ({ platform: f.platform, format: f.format, path: f.defaultPath, case: f.defaultCase }));
2787
+ }
2788
+ function withDefaults(existing, platforms) {
2789
+ const covered = new Set(existing.map((o) => o.platform));
2790
+ return [...existing, ...defaultOutputs(platforms.filter((p) => !covered.has(p)))];
2791
+ }
2792
+ var outputId = (o) => `${o.platform}-${o.format}`;
2793
+ function parseOutput(value, index) {
2794
+ const at = `speclayer.json outputs[${index}]`;
2795
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${at} must be an object.`);
2796
+ const r = value;
2797
+ if (typeof r.platform !== "string" || typeof r.format !== "string") {
2798
+ throw new Error(`${at} needs "platform" and "format". Known: ${knownFormats()}.`);
2799
+ }
2800
+ const spec = specOf(r.platform, r.format);
2801
+ if (!spec) throw new Error(`${at}: unknown platform/format "${r.platform}/${r.format}". Known: ${knownFormats()}.`);
2802
+ const str = (key) => {
2803
+ if (r[key] !== void 0 && typeof r[key] !== "string") throw new Error(`${at} "${key}" must be a string.`);
2804
+ return r[key];
2805
+ };
2806
+ const path = str("path") ?? spec.defaultPath;
2807
+ if (path === null) throw new Error(`${at} needs "path": ${spec.platform} has no default location.`);
2808
+ const nameCase = str("case");
2809
+ if (nameCase !== void 0 && !NAME_CASES.includes(nameCase)) {
2810
+ throw new Error(`${at} "case" takes ${NAME_CASES.join(", ")}.`);
2811
+ }
2812
+ const root = str("root");
2813
+ const modeSelector = str("modeSelector");
2814
+ let modes;
2815
+ if (r.modes !== void 0) {
2816
+ const m = r.modes;
2817
+ if (typeof m !== "object" || m === null || Array.isArray(m) || !Object.values(m).every((v) => typeof v === "string")) {
2818
+ throw new Error(`${at} "modes" must map collection names to selector strings.`);
2819
+ }
2820
+ modes = m;
2821
+ }
2822
+ return {
2823
+ platform: spec.platform,
2824
+ format: spec.format,
2825
+ path,
2826
+ case: nameCase ?? spec.defaultCase,
2827
+ ...root !== void 0 ? { root } : {},
2828
+ ...modeSelector !== void 0 ? { modeSelector } : {},
2829
+ ...modes ? { modes } : {}
2830
+ };
2831
+ }
2832
+ function renderOutput(exp, o, header) {
2833
+ switch (o.format) {
2834
+ case "css":
2835
+ return cssOutput(exp, { ...header, platform: o.platform, format: o.format }, {
2836
+ case: o.case,
2837
+ ...o.root !== void 0 ? { root: o.root } : {},
2838
+ ...o.modeSelector !== void 0 ? { modeSelector: o.modeSelector } : {},
2839
+ ...o.modes ? { modes: o.modes } : {}
2840
+ });
2841
+ default: {
2842
+ const exhaustive = o.format;
2843
+ return exhaustive;
2844
+ }
2845
+ }
2846
+ }
2847
+ var inside = (parent, child) => {
2848
+ const rel = relative(parent, child);
2849
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2850
+ };
2851
+ function outputPathProblem(cwd, outDir, o) {
2852
+ const root = resolve(cwd);
2853
+ const abs = resolve(cwd, o.path);
2854
+ if (!inside(root, abs) || abs === root) return `${o.path} is outside this directory. Choose a path inside the repository.`;
2855
+ if (inside(resolve(cwd, outDir), abs)) {
2856
+ return `${o.path} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
2857
+ }
2858
+ if (existsSync3(abs)) {
2859
+ const prefix = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
2860
+ let head;
2861
+ try {
2862
+ head = readFileSync3(abs, "utf8").slice(0, prefix.length);
2863
+ } catch {
2864
+ return `${o.path} exists and could not be read.`;
2865
+ }
2866
+ if (head !== prefix) return `${o.path} exists and was not written by spec-layer. Choose another path or remove the file.`;
2867
+ }
2868
+ return null;
2869
+ }
2870
+ function writeOutputFile(cwd, o, text) {
2871
+ const abs = resolve(cwd, o.path);
2872
+ mkdirSync(dirname(abs), { recursive: true });
2873
+ const partial = `${abs}.partial`;
2874
+ writeFileSync2(partial, text);
2875
+ try {
2876
+ renameSync(partial, abs);
2877
+ } catch (err) {
2878
+ rmSync(partial, { force: true });
2879
+ throw err;
2880
+ }
2881
+ }
2882
+
2158
2883
  // src/config.ts
2159
2884
  var DEFAULT_API = "https://api.spec-layer.com";
2160
2885
  var DEFAULT_OUT_DIR = ".speclayer";
@@ -2164,12 +2889,12 @@ function parseInclude(value) {
2164
2889
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
2165
2890
  const record = value;
2166
2891
  if (record.foundation !== void 0 && typeof record.foundation !== "boolean") throw invalidConfig();
2167
- if (record.components !== void 0 && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
2892
+ if (record.components !== void 0 && record.components !== null && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
2168
2893
  throw invalidConfig();
2169
2894
  }
2170
2895
  return {
2171
2896
  foundation: record.foundation === void 0 ? true : record.foundation,
2172
- components: record.components === void 0 ? null : record.components
2897
+ components: record.components ?? null
2173
2898
  };
2174
2899
  }
2175
2900
  function parseDtcg(value) {
@@ -2189,12 +2914,31 @@ function parseDtcg(value) {
2189
2914
  }
2190
2915
  return out;
2191
2916
  }
2917
+ function parsePlatforms(value) {
2918
+ if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
2919
+ throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
2920
+ }
2921
+ return [...new Set(value)];
2922
+ }
2923
+ function parseOutputs(value) {
2924
+ if (!Array.isArray(value)) throw new Error('speclayer.json "outputs" must be an array.');
2925
+ const outputs = value.map((v, i) => parseOutput(v, i));
2926
+ const seen = /* @__PURE__ */ new Set();
2927
+ for (const output of outputs) {
2928
+ const key = `${output.platform}/${output.format}`;
2929
+ if (seen.has(key)) {
2930
+ throw new Error(`speclayer.json "outputs" lists ${output.platform}/${output.format} more than once. Keep one entry per platform and format.`);
2931
+ }
2932
+ seen.add(key);
2933
+ }
2934
+ return outputs;
2935
+ }
2192
2936
  function readConfig(cwd) {
2193
- const path = join2(cwd, CONFIG_NAME);
2194
- if (!existsSync2(path)) return null;
2937
+ const path = join3(cwd, CONFIG_NAME);
2938
+ if (!existsSync4(path)) return null;
2195
2939
  let parsed;
2196
2940
  try {
2197
- parsed = JSON.parse(readFileSync2(path, "utf8"));
2941
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2198
2942
  } catch {
2199
2943
  throw invalidConfig();
2200
2944
  }
@@ -2204,7 +2948,9 @@ function readConfig(cwd) {
2204
2948
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
2205
2949
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
2206
2950
  ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2207
- ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
2951
+ ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
2952
+ ...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
2953
+ ...record.outputs !== void 0 ? { outputs: parseOutputs(record.outputs) } : {}
2208
2954
  };
2209
2955
  }
2210
2956
  function writeConfig(cwd, config) {
@@ -2212,15 +2958,17 @@ function writeConfig(cwd, config) {
2212
2958
  libraryId: config.libraryId,
2213
2959
  outDir: config.outDir,
2214
2960
  ...config.include ? { include: config.include } : {},
2215
- ...config.dtcg ? { dtcg: config.dtcg } : {}
2961
+ ...config.dtcg ? { dtcg: config.dtcg } : {},
2962
+ ...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
2963
+ ...config.outputs ? { outputs: config.outputs } : {}
2216
2964
  };
2217
- writeFileSync2(join2(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2965
+ writeFileSync3(join3(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2218
2966
  `);
2219
2967
  }
2220
2968
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
2221
2969
  const config = readConfig(cwd);
2222
2970
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
2223
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join2(cwd, outDir));
2971
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join3(cwd, outDir));
2224
2972
  const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2225
2973
  let storedKey = null;
2226
2974
  let storedKeyFor;
@@ -2239,6 +2987,8 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
2239
2987
  key: supplied ?? storedKey,
2240
2988
  ...config?.include ? { include: config.include } : {},
2241
2989
  ...config?.dtcg ? { dtcg: config.dtcg } : {},
2990
+ ...config?.platforms ? { platforms: config.platforms } : {},
2991
+ ...config?.outputs ? { outputs: config.outputs } : {},
2242
2992
  ...storedKeyFor ? { storedKeyFor } : {}
2243
2993
  };
2244
2994
  }
@@ -2277,8 +3027,8 @@ async function fetchBundle(opts) {
2277
3027
  }
2278
3028
 
2279
3029
  // src/files.ts
2280
- import { mkdirSync, writeFileSync as writeFileSync3, readFileSync as readFileSync3, readdirSync, rmSync, renameSync, existsSync as existsSync3 } from "node:fs";
2281
- import { join as join3, dirname, relative, resolve, isAbsolute } from "node:path";
3030
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync2, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3031
+ import { join as join4, dirname as dirname2, relative as relative2, resolve as resolve2, isAbsolute as isAbsolute2 } from "node:path";
2282
3032
 
2283
3033
  // src/selection.ts
2284
3034
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -2317,19 +3067,27 @@ function slugify(name) {
2317
3067
  return slug2 || "component";
2318
3068
  }
2319
3069
  function readManifest(outDir) {
2320
- const path = join3(outDir, "manifest.json");
2321
- if (!existsSync3(path)) return null;
3070
+ const path = join4(outDir, "manifest.json");
3071
+ if (!existsSync5(path)) return null;
2322
3072
  try {
2323
- return JSON.parse(readFileSync3(path, "utf8"));
3073
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3074
+ parsed.artifacts = parsed.artifacts.map((artifact) => {
3075
+ const { aiPath, ...rest } = artifact;
3076
+ return {
3077
+ ...rest,
3078
+ path: rest.path ?? aiPath ?? null
3079
+ };
3080
+ });
3081
+ return parsed;
2324
3082
  } catch {
2325
3083
  return null;
2326
3084
  }
2327
3085
  }
2328
3086
  function readLocalBundle(outDir) {
2329
- const path = join3(outDir, "bundle.json");
2330
- if (!existsSync3(path)) return null;
3087
+ const path = join4(outDir, "bundle.json");
3088
+ if (!existsSync5(path)) return null;
2331
3089
  try {
2332
- return parseBundle(readFileSync3(path, "utf8"));
3090
+ return parseBundle(readFileSync5(path, "utf8"));
2333
3091
  } catch {
2334
3092
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
2335
3093
  }
@@ -2356,11 +3114,11 @@ function componentSlugs(bundle) {
2356
3114
  });
2357
3115
  }
2358
3116
  function assertReplaceable(outDir, cwd) {
2359
- const rel = relative(resolve(cwd), resolve(outDir));
2360
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
3117
+ const rel = relative2(resolve2(cwd), resolve2(outDir));
3118
+ if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
2361
3119
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
2362
3120
  }
2363
- if (existsSync3(outDir) && !existsSync3(join3(outDir, "manifest.json")) && readdirSync(outDir).length > 0) {
3121
+ if (existsSync5(outDir) && !existsSync5(join4(outDir, "manifest.json")) && readdirSync2(outDir).length > 0) {
2364
3122
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
2365
3123
  }
2366
3124
  }
@@ -2369,44 +3127,62 @@ function writeBundleFiles(opts) {
2369
3127
  const selection = opts.selection ?? DEFAULT_SELECTION;
2370
3128
  const selected = selectComponents(opts.bundle, selection);
2371
3129
  const slugs = componentSlugs(opts.bundle);
3130
+ const outputs = opts.outputs ?? [];
3131
+ const willWriteFoundation = Boolean(opts.bundle.foundation) && selection.foundation;
3132
+ if (willWriteFoundation) {
3133
+ for (const o of outputs) {
3134
+ const problem = outputPathProblem(opts.cwd, opts.outDir, o);
3135
+ if (problem) throw new Error(problem);
3136
+ }
3137
+ }
2372
3138
  const staging = `${opts.outDir}.partial`;
2373
- rmSync(staging, { recursive: true, force: true });
3139
+ rmSync2(staging, { recursive: true, force: true });
2374
3140
  const written = [];
3141
+ const deliverables = [];
3142
+ const json = (v) => `${JSON.stringify(v, null, 2)}
3143
+ `;
2375
3144
  const put = (rel, content) => {
2376
- const path = join3(staging, rel);
2377
- mkdirSync(dirname(path), { recursive: true });
2378
- writeFileSync3(path, content);
3145
+ const path = join4(staging, rel);
3146
+ mkdirSync2(dirname2(path), { recursive: true });
3147
+ writeFileSync4(path, content);
2379
3148
  written.push(rel);
2380
3149
  };
2381
3150
  try {
2382
3151
  put("bundle.json", opts.raw);
2383
3152
  const artifacts = [];
2384
3153
  if (opts.bundle.foundation) {
2385
- let aiPath = null;
3154
+ let path = null;
2386
3155
  if (selection.foundation) {
2387
3156
  const artifact = opts.bundle.foundation.artifact;
2388
3157
  if (validateLevel1(artifact).some((d) => d.severity === "error")) {
2389
3158
  throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
2390
3159
  }
2391
- const files = dtcgExportFiles(foundationDtcg(artifact, opts.dtcg ?? {}));
2392
- for (const [name, text] of Object.entries(files)) put(`tokens/${name}`, text);
2393
- aiPath = "tokens/resolver.json";
3160
+ const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3161
+ for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3162
+ path = "tokens/resolver.json";
3163
+ const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
3164
+ for (const output of outputs) {
3165
+ const rendered = renderOutput(exp, output, header);
3166
+ put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
3167
+ put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
3168
+ deliverables.push({ output, text: rendered.text });
3169
+ }
2394
3170
  }
2395
3171
  artifacts.push({
2396
3172
  kind: "foundation",
2397
3173
  name: "foundation",
2398
3174
  contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash,
2399
- aiPath
3175
+ path
2400
3176
  });
2401
3177
  }
2402
3178
  opts.bundle.components.forEach((component, i) => {
2403
- const aiPath = selected[i] ? `ai/components/${slugs[i]}.yaml` : null;
2404
- if (aiPath) put(aiPath, component.ai);
3179
+ const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
3180
+ if (path) put(path, component.ai);
2405
3181
  artifacts.push({
2406
3182
  kind: "component",
2407
3183
  name: component.name,
2408
3184
  contentHash: component.artifact.spec_layer.export.content_hash,
2409
- aiPath
3185
+ path
2410
3186
  });
2411
3187
  });
2412
3188
  const manifest = {
@@ -2417,23 +3193,29 @@ function writeBundleFiles(opts) {
2417
3193
  extractorVersion: opts.bundle.extractorVersion,
2418
3194
  selection,
2419
3195
  artifacts,
2420
- ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
3196
+ ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
3197
+ ...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
3198
+ ...opts.outputs ? { outputs: opts.outputs } : {}
2421
3199
  };
2422
- put("manifest.json", `${JSON.stringify(manifest, null, 2)}
2423
- `);
3200
+ put("manifest.json", json(manifest));
2424
3201
  } catch (err) {
2425
- rmSync(staging, { recursive: true, force: true });
3202
+ rmSync2(staging, { recursive: true, force: true });
2426
3203
  throw err;
2427
3204
  }
2428
- rmSync(opts.outDir, { recursive: true, force: true });
2429
- renameSync(staging, opts.outDir);
2430
- return written;
3205
+ rmSync2(opts.outDir, { recursive: true, force: true });
3206
+ renameSync2(staging, opts.outDir);
3207
+ const outputPaths = [];
3208
+ for (const d of deliverables) {
3209
+ writeOutputFile(opts.cwd, d.output, d.text);
3210
+ outputPaths.push(d.output.path);
3211
+ }
3212
+ return { written, outputs: outputPaths };
2431
3213
  }
2432
3214
 
2433
3215
  // src/gitignore.ts
2434
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "node:fs";
3216
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
2435
3217
  import { spawnSync } from "node:child_process";
2436
- import { join as join4, dirname as dirname2, resolve as resolve2 } from "node:path";
3218
+ import { join as join5, dirname as dirname3, resolve as resolve3 } from "node:path";
2437
3219
  var COMMENT = "# Spec Layer pull key, not for committing";
2438
3220
  function git(cwd, args) {
2439
3221
  const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
@@ -2441,10 +3223,10 @@ function git(cwd, args) {
2441
3223
  return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
2442
3224
  }
2443
3225
  function insideWorkTreeWithoutGit(cwd) {
2444
- let dir = resolve2(cwd);
3226
+ let dir = resolve3(cwd);
2445
3227
  for (; ; ) {
2446
- if (existsSync4(join4(dir, ".git"))) return true;
2447
- const parent = dirname2(dir);
3228
+ if (existsSync6(join5(dir, ".git"))) return true;
3229
+ const parent = dirname3(dir);
2448
3230
  if (parent === dir) return false;
2449
3231
  dir = parent;
2450
3232
  }
@@ -2460,18 +3242,18 @@ function ensureIgnored(cwd, fileName) {
2460
3242
  if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
2461
3243
  const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
2462
3244
  if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
2463
- const path = join4(cwd, ".gitignore");
2464
- const existed = existsSync4(path);
3245
+ const path = join5(cwd, ".gitignore");
3246
+ const existed = existsSync6(path);
2465
3247
  try {
2466
3248
  if (!existed) {
2467
- writeFileSync4(path, `${COMMENT}
3249
+ writeFileSync5(path, `${COMMENT}
2468
3250
  ${fileName}
2469
3251
  `);
2470
3252
  } else {
2471
- const body = readFileSync4(path, "utf8");
3253
+ const body = readFileSync6(path, "utf8");
2472
3254
  if (!hasEntryLine(body, fileName)) {
2473
3255
  const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
2474
- writeFileSync4(path, `${body}${lead}${COMMENT}
3256
+ writeFileSync5(path, `${body}${lead}${COMMENT}
2475
3257
  ${fileName}
2476
3258
  `);
2477
3259
  }
@@ -2484,6 +3266,524 @@ ${fileName}
2484
3266
  return existed ? { kind: "added" } : { kind: "created" };
2485
3267
  }
2486
3268
 
3269
+ // src/skill.ts
3270
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3271
+ import { dirname as dirname4, join as join6 } from "node:path";
3272
+
3273
+ // src/tools.ts
3274
+ var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
3275
+ var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
3276
+ var TOOLS = [
3277
+ {
3278
+ name: "setup",
3279
+ usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3280
+ summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
3281
+ when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
3282
+ network: true,
3283
+ needsKey: true,
3284
+ writes: [
3285
+ "speclayer.json",
3286
+ "speclayer.local.json",
3287
+ ".gitignore (one line, when inside a git repo)",
3288
+ "<outDir>/",
3289
+ "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"
3290
+ ],
3291
+ exits: OK_OR_ERROR
3292
+ },
3293
+ {
3294
+ name: "init",
3295
+ usage: "spec-layer init --id lib_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3296
+ summary: "Writes speclayer.json, with the platforms and default outputs, so later commands need no flags. Stores no key and reaches no server.",
3297
+ when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
3298
+ network: false,
3299
+ needsKey: false,
3300
+ writes: ["speclayer.json"],
3301
+ exits: { "0": "success", "1": "usage error" }
3302
+ },
3303
+ {
3304
+ name: "pull",
3305
+ usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3306
+ summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
3307
+ when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block, or the outputs block.",
3308
+ network: true,
3309
+ needsKey: true,
3310
+ writes: ["<outDir>/", "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"],
3311
+ exits: OK_OR_ERROR
3312
+ },
3313
+ {
3314
+ name: "status",
3315
+ usage: "spec-layer status [--id lib_...] [--key sl_...] [--out DIR]",
3316
+ summary: "Checks whether the local pull is current without writing anything.",
3317
+ when: "Before reading the pulled files, or in CI; exit 2 means run pull.",
3318
+ network: true,
3319
+ needsKey: true,
3320
+ writes: [],
3321
+ exits: { "0": "up to date", "1": "usage error, bad key or id, or a network or server failure", "2": "behind, or no local pull yet" }
3322
+ },
3323
+ {
3324
+ name: "list",
3325
+ usage: "spec-layer list [--out DIR]",
3326
+ summary: 'Lists every artifact in the last pull with its file path, or "not written" when the selection skipped it.',
3327
+ when: "To learn which components the library documents and where each file is.",
3328
+ network: false,
3329
+ needsKey: false,
3330
+ writes: [],
3331
+ exits: LOCAL_ONLY
3332
+ },
3333
+ {
3334
+ name: "show",
3335
+ usage: "spec-layer show foundation | component NAME [--canonical] [--out DIR]",
3336
+ summary: "Prints one artifact to stdout: the Foundation DTCG document, or one component's AI YAML; --canonical prints the v5 JSON.",
3337
+ when: "To read one component or the token document without opening files; it pipes cleanly.",
3338
+ network: false,
3339
+ needsKey: false,
3340
+ writes: [],
3341
+ exits: LOCAL_ONLY
3342
+ },
3343
+ {
3344
+ name: "tools",
3345
+ usage: "spec-layer tools [--json]",
3346
+ summary: "Prints this list of commands, with what each reaches and writes.",
3347
+ when: "A coding agent deciding which command to run; --json is stable for machines.",
3348
+ network: false,
3349
+ needsKey: false,
3350
+ writes: [],
3351
+ exits: { "0": "success" }
3352
+ },
3353
+ {
3354
+ name: "skill",
3355
+ usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter]... [--json] [--out DIR]",
3356
+ summary: "Prints a guide for a coding agent, adapted to this repository's stack and to the last pull; --install writes it where the agent reads instructions.",
3357
+ when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
3358
+ network: false,
3359
+ needsKey: false,
3360
+ writes: ["agent instruction files (only with --install; each path is printed)"],
3361
+ exits: { "0": "success", "1": "usage error, or a file could not be written" }
3362
+ }
3363
+ ];
3364
+ var GLOBAL_FLAGS = [
3365
+ { flag: "--api URL", summary: "Override the API origin (default https://api.spec-layer.com). Also SPEC_LAYER_API." },
3366
+ { flag: "--out DIR", summary: "Output directory (default .speclayer, or the outDir in speclayer.json)." }
3367
+ ];
3368
+ var KEY_RESOLUTION = "The pull key resolves from --key, then SPEC_LAYER_KEY, then speclayer.local.json written by setup. No command ever prints it.";
3369
+ function toolsText() {
3370
+ const lines = ["spec-layer commands", ""];
3371
+ for (const tool of TOOLS) {
3372
+ lines.push(tool.usage);
3373
+ lines.push(` ${tool.summary}`);
3374
+ lines.push(` When: ${tool.when}`);
3375
+ lines.push(` Network: ${tool.network ? "yes" : "no"}. Key: ${tool.needsKey ? "required" : "not needed"}. Writes: ${tool.writes.length ? tool.writes.join(", ") : "nothing"}.`);
3376
+ lines.push(` Exits: ${Object.entries(tool.exits).map(([code2, meaning]) => `${code2} ${meaning}`).join("; ")}.`);
3377
+ lines.push("");
3378
+ }
3379
+ lines.push("Flags every command accepts where they apply:");
3380
+ for (const f of GLOBAL_FLAGS) lines.push(` ${f.flag} ${f.summary}`);
3381
+ lines.push("");
3382
+ lines.push(KEY_RESOLUTION);
3383
+ return lines.join("\n");
3384
+ }
3385
+ function toolsJson(version) {
3386
+ return `${JSON.stringify({
3387
+ cli: "spec-layer",
3388
+ version,
3389
+ tools: TOOLS.map((t) => ({ ...t })),
3390
+ flags: GLOBAL_FLAGS,
3391
+ key_resolution: KEY_RESOLUTION
3392
+ }, null, 2)}
3393
+ `;
3394
+ }
3395
+
3396
+ // src/skill.ts
3397
+ var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
3398
+ function countNumberTokens(tree) {
3399
+ if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return 0;
3400
+ const record = tree;
3401
+ if (record.$type === "number" && "$value" in record) return 1;
3402
+ let n = 0;
3403
+ for (const [key, value] of Object.entries(record)) {
3404
+ if (key.startsWith("$")) continue;
3405
+ n += countNumberTokens(value);
3406
+ }
3407
+ return n;
3408
+ }
3409
+ function readJson(path) {
3410
+ if (!existsSync7(path)) return null;
3411
+ try {
3412
+ return JSON.parse(readFileSync7(path, "utf8"));
3413
+ } catch {
3414
+ return null;
3415
+ }
3416
+ }
3417
+ function summarizePull(cwd, outDir, manifest) {
3418
+ if (!manifest) return null;
3419
+ const absOut = join6(cwd, outDir);
3420
+ const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path ? `${outDir}/${a.path}` : null }));
3421
+ const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
3422
+ let foundation = null;
3423
+ if (foundationEntry) {
3424
+ const tokensDir = join6(absOut, "tokens");
3425
+ const resolver = readJson(join6(tokensDir, "resolver.json"));
3426
+ const report2 = readJson(join6(tokensDir, "report.json"));
3427
+ let tokenFiles = [];
3428
+ try {
3429
+ tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
3430
+ } catch {
3431
+ tokenFiles = [];
3432
+ }
3433
+ let unitlessNumbers = 0;
3434
+ for (const file of tokenFiles) {
3435
+ if (file.startsWith("styles.")) continue;
3436
+ unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
3437
+ }
3438
+ const reportCounts = {};
3439
+ if (Array.isArray(report2)) {
3440
+ for (const entry2 of report2) {
3441
+ if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
3442
+ }
3443
+ }
3444
+ foundation = {
3445
+ written: foundationEntry.path !== null && resolver !== null,
3446
+ sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
3447
+ modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
3448
+ name,
3449
+ contexts: Object.keys(m.contexts ?? {}),
3450
+ default: m.default ?? null
3451
+ })) : [],
3452
+ tokenFiles,
3453
+ unitlessNumbers,
3454
+ reportCounts
3455
+ };
3456
+ }
3457
+ return {
3458
+ outDir,
3459
+ libraryId: manifest.libraryId,
3460
+ publishedAt: manifest.publishedAt,
3461
+ pluginVersion: manifest.pluginVersion,
3462
+ components,
3463
+ foundation,
3464
+ outputs: (manifest.outputs ?? []).map((o) => ({
3465
+ platform: o.platform,
3466
+ format: o.format,
3467
+ path: o.path,
3468
+ case: o.case,
3469
+ modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
3470
+ modes: o.modes ?? {},
3471
+ // manifest.outputs records the configured list regardless of whether the
3472
+ // Foundation was written; the map file exists only when it was actually
3473
+ // rendered, so it is the on-disk proof a sentence can point to.
3474
+ written: existsSync7(join6(absOut, "outputs", `${o.platform}-${o.format}.map.json`))
3475
+ }))
3476
+ };
3477
+ }
3478
+ var code = (s) => `\`${s}\``;
3479
+ function stackSection(input) {
3480
+ const { profile, platforms, platformSource, pull } = input;
3481
+ const lines = ["## This codebase", ""];
3482
+ if (profile.evidence.length === 0) {
3483
+ lines.push(
3484
+ "Nothing at the root of this directory identified a language, framework, or platform. "
3485
+ );
3486
+ } else {
3487
+ lines.push("Detected from the repository root (the file that carries each signal is named, and nothing deeper was read):", "");
3488
+ for (const e of profile.evidence) lines.push(`- ${e.signal} (${code(e.file)})`);
3489
+ lines.push("");
3490
+ const facts = [];
3491
+ if (profile.languages.length) facts.push(`Languages: ${profile.languages.join(", ")}.`);
3492
+ if (profile.frameworks.length) facts.push(`Frameworks: ${profile.frameworks.join(", ")}.`);
3493
+ if (profile.tokenTools.length) facts.push(`Token tooling: ${profile.tokenTools.join(", ")}.`);
3494
+ if (facts.length) lines.push(facts.join(" "), "");
3495
+ }
3496
+ if (platformSource === "none") {
3497
+ lines.push(
3498
+ `No target platform was detected, so the token advice below is generic. Re-run ${code("spec-layer skill --platform web|ios|android|flutter")} to write it for a platform, or pass ` + code("--install") + " with the same flag to update the installed copy.",
3499
+ ""
3500
+ );
3501
+ } else {
3502
+ const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
3503
+ lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
3504
+ }
3505
+ for (const platform of platforms) {
3506
+ const key = CODE_SYNTAX_KEY[platform];
3507
+ const tokensDir = `${input.outDir}/tokens/`;
3508
+ if (platform === "web") {
3509
+ lines.push("### Web", "");
3510
+ const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
3511
+ if (cssOut) {
3512
+ const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3513
+ lines.push(
3514
+ `The CSS custom property for every token is in ${code(mapPath)}: source "code_syntax" when the designer declared it in Figma, "derived" when the CLI built it from the DTCG path by the stated rule (${cssOut.case} case, collection root included). Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
3515
+ ""
3516
+ );
3517
+ lines.push(
3518
+ `Import ${code(cssOut.path)} from the root stylesheet. It holds every set and every default mode at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)}. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). Wire it to prefers-color-scheme yourself if the OS should choose; the file never assumes that.`,
3519
+ ""
3520
+ );
3521
+ if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
3522
+ lines.push(
3523
+ `${code(cssOut.path)} is a projection of the same ${code("tokens/")} files, not a second source. Import one or the other.`,
3524
+ ""
3525
+ );
3526
+ }
3527
+ } else {
3528
+ lines.push(
3529
+ `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. When a token has no WEB entry, use the DTCG path as it appears in the token file and say in your change that the code name is not declared in Figma.`,
3530
+ ""
3531
+ );
3532
+ const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
3533
+ if (configuredNotWritten) {
3534
+ lines.push(
3535
+ `A web/css output is configured at ${code(configuredNotWritten.path)} but was not written, because the last pull did not write the Foundation. Pull with the Foundation selected to write it.`,
3536
+ ""
3537
+ );
3538
+ } else if (pull?.foundation?.written) {
3539
+ lines.push(
3540
+ `No token file was written for web. Add \`"outputs"\` in \`speclayer.json\` (or run \`spec-layer pull --platform web\` once) and pull again; the default lands at ${code("spec-layer/tokens.css")}.`,
3541
+ ""
3542
+ );
3543
+ }
3544
+ }
3545
+ if (profile.tokenTools.includes("tailwind")) {
3546
+ lines.push(
3547
+ `Tailwind is present (${code("tailwindcss")}). Map DTCG ${code("color")} tokens to the theme's color scale and ${code("dimension")} tokens to spacing, radius, or font size by the collection and group they sit in. Keep the mapping in one place and reference token paths, not copied values, so a republish moves the code with it.`,
3548
+ ""
3549
+ );
3550
+ }
3551
+ if (profile.tokenTools.includes("style-dictionary")) {
3552
+ const major = profile.styleDictionaryMajor;
3553
+ lines.push(
3554
+ `Style Dictionary is present${major !== null ? ` (major version ${major} in package.json)` : ""}. Point it at ${code(tokensDir)} and load the files ${code("resolver.json")} names for the mode you build. Exclude ${code("spec-layer.meta.json")} and ${code("report.json")} from token globs; they are not token files.`
3555
+ );
3556
+ if (major !== null && major < 5) {
3557
+ lines.push(
3558
+ "",
3559
+ `Style Dictionary ${major} reads the string value forms, not the 2025.10 object forms. Set ${code('"dtcg": { "values": "legacy" }')} in ${code("speclayer.json")} and run ${code("spec-layer pull")}; the change re-projects tokens/ without a republish.`
3560
+ );
3561
+ }
3562
+ lines.push("");
3563
+ }
3564
+ } else if (platform === "ios") {
3565
+ lines.push("### iOS", "");
3566
+ lines.push(
3567
+ `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Swift symbol. Colors arrive as hex strings or DTCG color objects with RGBA components; dimensions carry an explicit px or rem unit. A modifier with more than one context (for example a light and a dark mode) maps to a color-scheme-dependent value; a set without modes is a constant. Do not invent a dark variant for a collection that has one mode.`,
3568
+ ""
3569
+ );
3570
+ } else if (platform === "android") {
3571
+ lines.push("### Android", "");
3572
+ lines.push(
3573
+ `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Kotlin or resource name. Dimensions carry an explicit px or rem unit and no density assumption; a value is only dp when your own convention says so, and that convention belongs in your code, not in the token file. Modes map to resource qualifiers or a Compose theme switch.`,
3574
+ ""
3575
+ );
3576
+ } else {
3577
+ lines.push("### Flutter", "");
3578
+ lines.push(
3579
+ `Figma declares no code syntax for Flutter, so no identifier is provided for Dart. Name symbols after the DTCG path (for example ${code("Collection.group.name")} becomes a nested class or a camelCase constant) and keep the path in a comment so the source token stays traceable. Modes map to theme variants.`,
3580
+ ""
3581
+ );
3582
+ }
3583
+ }
3584
+ if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
3585
+ const n = pull.foundation.unitlessNumbers;
3586
+ lines.push(
3587
+ `${n} token${n === 1 ? " is" : "s are"} exported as ${code('$type: "number"')} because the Figma scopes state no unit. If your code needs them as px or rem, declare it in ${code("speclayer.json")}: ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')}, then run ${code("spec-layer pull")}. Nothing is inferred from a name; an override that contradicts a stated scope is ignored and listed in ${code("report.json")}.`,
3588
+ ""
3589
+ );
3590
+ }
3591
+ return lines;
3592
+ }
3593
+ function pullSection(input) {
3594
+ const { pull, outDir } = input;
3595
+ const lines = ["## What is on disk", ""];
3596
+ if (!pull) {
3597
+ lines.push(
3598
+ `No pull has been made in this directory yet, so nothing under ${code(outDir + "/")} can be described. Run ${code("npx spec-layer pull")} (or the setup command from the plugin if there is no ${code("speclayer.json")}), then ${code("npx spec-layer skill --install")} again to list the components and token collections here.`,
3599
+ ""
3600
+ );
3601
+ return lines;
3602
+ }
3603
+ lines.push(
3604
+ `Library ${code(pull.libraryId)}, published ${pull.publishedAt}${pull.pluginVersion ? ` by plugin ${pull.pluginVersion}` : ""}. Run ${code("npx spec-layer status")} first; exit code 2 means a newer publish exists and ${code("npx spec-layer pull")} fetches it.`,
3605
+ ""
3606
+ );
3607
+ lines.push(`- ${code(`${outDir}/manifest.json`)}: every artifact with its content hash and file path.`);
3608
+ lines.push(`- ${code(`${outDir}/bundle.json`)}: the whole published library, including the canonical v5 JSON of every artifact.`);
3609
+ if (pull.foundation) {
3610
+ if (pull.foundation.written) {
3611
+ lines.push(`- ${code(`${outDir}/tokens/`)}: the Foundation as Design Tokens Format Module 2025.10 files.`);
3612
+ lines.push(` - ${code("resolver.json")}: sets, modifiers, and resolution order. Start here.`);
3613
+ lines.push(` - ${code("spec-layer.meta.json")}: Figma ids, scopes, publication, and ${code("code_syntax")} per DTCG path.`);
3614
+ lines.push(` - ${code("report.json")}: what the format could not express, with reasons. Never fill these gaps with a guess.`);
3615
+ for (const f of pull.foundation.tokenFiles) lines.push(` - ${code(f)}`);
3616
+ } else {
3617
+ lines.push(`- Foundation: present in the library but not written, because the selection excludes it. ${code("spec-layer show foundation")} still prints it.`);
3618
+ }
3619
+ } else {
3620
+ lines.push("- This library has no Foundation, so there is no tokens/ directory.");
3621
+ }
3622
+ lines.push(`- ${code(`${outDir}/components/`)}: one YAML per component.`);
3623
+ for (const o of pull.outputs) {
3624
+ lines.push(o.written ? `- ${code(o.path)}: ${o.platform}/${o.format} token file, ${o.case} names, modes under ${code(o.modeSelector)}. Names and provenance: ${code(`${outDir}/outputs/${o.platform}-${o.format}.map.json`)}; what it could not express: ${code(`${outDir}/outputs/${o.platform}-${o.format}.report.json`)}.` : `- ${code(o.path)}: ${o.platform}/${o.format} token file, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
3625
+ }
3626
+ lines.push("");
3627
+ if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
3628
+ lines.push("### Token collections", "");
3629
+ for (const set of pull.foundation.sets) lines.push(`- ${code(set)}: one mode, always applied.`);
3630
+ for (const m of pull.foundation.modifiers) {
3631
+ lines.push(`- ${code(m.name)}: modes ${m.contexts.map(code).join(", ")}${m.default ? `, default ${code(m.default)}` : ""}.`);
3632
+ }
3633
+ lines.push("");
3634
+ const counts = Object.entries(pull.foundation.reportCounts);
3635
+ if (counts.length) {
3636
+ lines.push(
3637
+ `${code("report.json")} lists ${counts.map(([c, n]) => `${n} ${code(c)}`).join(", ")}. Read it before assuming a token is missing.`,
3638
+ ""
3639
+ );
3640
+ }
3641
+ }
3642
+ lines.push("### Components", "");
3643
+ if (pull.components.length === 0) {
3644
+ lines.push("The library documents no components.", "");
3645
+ } else {
3646
+ for (const c of pull.components) {
3647
+ lines.push(c.path ? `- ${c.name}: ${code(c.path)}` : `- ${c.name}: not written (excluded by the selection). ${code(`spec-layer show component "${c.name}"`)} prints it.`);
3648
+ }
3649
+ lines.push("");
3650
+ }
3651
+ return lines;
3652
+ }
3653
+ function commandsSection() {
3654
+ const lines = ["## Commands", ""];
3655
+ const cell = (s) => s.replace(/\|/g, "\\|");
3656
+ lines.push("| Command | What it does | When | Network | Key | Writes |", "|---|---|---|---|---|---|");
3657
+ for (const t of TOOLS) {
3658
+ lines.push(`| ${code(cell(t.usage))} | ${cell(t.summary)} | ${cell(t.when)} | ${t.network ? "yes" : "no"} | ${t.needsKey ? "required" : "no"} | ${t.writes.length ? t.writes.map((w) => code(cell(w))).join(", ") : "nothing"} |`);
3659
+ }
3660
+ lines.push("");
3661
+ lines.push("Exit codes:", "");
3662
+ for (const t of TOOLS) {
3663
+ lines.push(`- ${code(t.name)}: ${Object.entries(t.exits).map(([c, m]) => `${c} = ${m}`).join("; ")}.`);
3664
+ }
3665
+ lines.push("");
3666
+ for (const f of GLOBAL_FLAGS) lines.push(`- ${code(f.flag)}: ${f.summary}`);
3667
+ lines.push("", KEY_RESOLUTION, "");
3668
+ lines.push(`Run ${code("npx --yes spec-layer <command>")} in an unattended session so npx does not stop to ask before downloading the package. ${code("spec-layer tools --json")} prints this table for machines.`, "");
3669
+ return lines;
3670
+ }
3671
+ function buildSkillGuide(input) {
3672
+ const { outDir } = input;
3673
+ const lines = [];
3674
+ lines.push("# Spec Layer: design-system context for this repository", "");
3675
+ lines.push(
3676
+ `The Spec Layer Figma plugin publishes a design system's components, variables, and styles as data. The ${code("spec-layer")} CLI (version ${input.version}) pulls that data into this repository under ${code(outDir + "/")}. Everything in those files is extracted deterministically from Figma and validated against a published schema; no model wrote any of it. Treat it as the source of truth for what the design system contains, and treat anything it does not state as unknown rather than as something to infer.`,
3677
+ ""
3678
+ );
3679
+ lines.push("## How to use it", "");
3680
+ lines.push(`1. Run ${code("npx spec-layer status")}. Exit 0 means the local copy is current; exit 2 means run ${code("npx spec-layer pull")} first.`);
3681
+ lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
3682
+ lines.push(`3. Working with colors, spacing, type, or effects: start at ${code(`${outDir}/tokens/resolver.json`)}, load the set and mode files it names, and look up ${code("code_syntax")} in ${code("spec-layer.meta.json")} for the name the designer declared for your platform.`);
3683
+ lines.push(`4. Reference tokens by name in code; never paste a resolved value where a token exists. A value the design system does not define is not a token: say so in your change rather than adding one.`);
3684
+ lines.push(`5. An ${code("unbound")} entry is design debt reported from Figma. Do not silently promote it to a token; keep the literal and note that Figma has no binding for it.`);
3685
+ const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
3686
+ const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(o.path)).join(", ")} either: pull replaces ${writtenOutputs.length === 1 ? "it" : "them"} in place.` : "";
3687
+ lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3688
+ lines.push("");
3689
+ lines.push(...pullSection(input));
3690
+ lines.push(...stackSection(input));
3691
+ lines.push(...commandsSection());
3692
+ lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
3693
+ return `${lines.join("\n")}
3694
+ `;
3695
+ }
3696
+ var SKILL_DESCRIPTION = "Use the design-system context the Spec Layer Figma plugin published into this repository: component variants, states, anatomy, token bindings, and design tokens. Read this before building or changing UI, using tokens, or running the spec-layer CLI.";
3697
+ function installTarget(host) {
3698
+ switch (host) {
3699
+ case "claude":
3700
+ return { host, path: ".claude/skills/spec-layer/SKILL.md", mode: "file" };
3701
+ case "cursor":
3702
+ return { host, path: ".cursor/rules/spec-layer.mdc", mode: "file" };
3703
+ case "copilot":
3704
+ return { host, path: ".github/instructions/spec-layer.instructions.md", mode: "file" };
3705
+ case "windsurf":
3706
+ return { host, path: ".windsurf/rules/spec-layer.md", mode: "file" };
3707
+ case "gemini":
3708
+ return { host, path: "GEMINI.md", mode: "block" };
3709
+ case "agents-md":
3710
+ return { host, path: "AGENTS.md", mode: "block" };
3711
+ }
3712
+ }
3713
+ function renderForHost(host, guide) {
3714
+ const yamlString = (s) => JSON.stringify(s);
3715
+ switch (host) {
3716
+ case "claude":
3717
+ return `---
3718
+ name: spec-layer
3719
+ description: ${yamlString(SKILL_DESCRIPTION)}
3720
+ ---
3721
+
3722
+ ${guide}`;
3723
+ case "cursor":
3724
+ return `---
3725
+ description: ${yamlString(SKILL_DESCRIPTION)}
3726
+ alwaysApply: false
3727
+ ---
3728
+
3729
+ ${guide}`;
3730
+ case "copilot":
3731
+ return `---
3732
+ applyTo: "**"
3733
+ ---
3734
+
3735
+ ${guide}`;
3736
+ case "windsurf":
3737
+ return `---
3738
+ trigger: model_decision
3739
+ description: ${yamlString(SKILL_DESCRIPTION)}
3740
+ ---
3741
+
3742
+ ${guide}`;
3743
+ case "gemini":
3744
+ case "agents-md":
3745
+ return guide;
3746
+ }
3747
+ }
3748
+ var BLOCK_BEGIN = "<!-- spec-layer:begin -->";
3749
+ var BLOCK_END = "<!-- spec-layer:end -->";
3750
+ function upsertBlock(existing, guide) {
3751
+ const block = `${BLOCK_BEGIN}
3752
+ ${guide.trimEnd()}
3753
+ ${BLOCK_END}
3754
+ `;
3755
+ if (existing === null) return block;
3756
+ const begin = existing.indexOf(BLOCK_BEGIN);
3757
+ const end = existing.indexOf(BLOCK_END);
3758
+ if (begin !== -1 && end !== -1 && end > begin) {
3759
+ const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
3760
+ return `${existing.slice(0, begin)}${block}${after}`;
3761
+ }
3762
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3763
+ return `${existing}${sep}${block}`;
3764
+ }
3765
+ function installSkill(cwd, host, guide) {
3766
+ const target = installTarget(host);
3767
+ const abs = join6(cwd, target.path);
3768
+ const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
3769
+ const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3770
+ if (existing === next) return { path: target.path, result: "unchanged" };
3771
+ mkdirSync3(dirname4(abs), { recursive: true });
3772
+ writeFileSync6(abs, next);
3773
+ return { path: target.path, result: existing === null ? "created" : "updated" };
3774
+ }
3775
+
3776
+ // src/version.ts
3777
+ import { readFileSync as readFileSync8 } from "node:fs";
3778
+ function cliVersion() {
3779
+ try {
3780
+ const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
3781
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
3782
+ } catch {
3783
+ return "unknown";
3784
+ }
3785
+ }
3786
+
2487
3787
  // src/commands.ts
2488
3788
  var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
2489
3789
  function manifestReader() {
@@ -2495,8 +3795,8 @@ function manifestReader() {
2495
3795
  }
2496
3796
  function sameOutput(a, b) {
2497
3797
  const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
2498
- const dtcgKey = (d) => JSON.stringify(sortKeys(d ?? {}));
2499
- return selectionKey(a.selection) === selectionKey(b.selection) && dtcgKey(a.dtcg) === dtcgKey(b.dtcg);
3798
+ const key = (v) => JSON.stringify(sortKeys(v ?? {}));
3799
+ return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []);
2500
3800
  }
2501
3801
  function sortKeys(value) {
2502
3802
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -2505,6 +3805,35 @@ function sortKeys(value) {
2505
3805
  }
2506
3806
  return value;
2507
3807
  }
3808
+ function platformsFromFlags(flags, io2) {
3809
+ if (flags.platform === void 0 || flags.platform.length === 0) return void 0;
3810
+ const out = [];
3811
+ for (const value of flags.platform) {
3812
+ if (!isPlatform(value)) {
3813
+ io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${value}".`);
3814
+ return null;
3815
+ }
3816
+ if (!out.includes(value)) out.push(value);
3817
+ }
3818
+ return out;
3819
+ }
3820
+ function resolvePlatforms(cwd, fromFlags, config) {
3821
+ if (fromFlags) return { platforms: fromFlags, source: "flag" };
3822
+ if (config?.platforms && config.platforms.length > 0) return { platforms: config.platforms, source: "config" };
3823
+ const detected = detectRepo(cwd).platforms;
3824
+ return { platforms: detected, source: detected.length > 0 ? "detected" : "none" };
3825
+ }
3826
+ function outputsForRun(fromFlags, config, platforms) {
3827
+ if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
3828
+ return config?.outputs ?? defaultOutputs(platforms);
3829
+ }
3830
+ var NO_PLATFORM_NOTE = `No target platform detected, so no token file was written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
3831
+ function platformsMissingFormat(platforms) {
3832
+ return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
3833
+ }
3834
+ function missingFormatNote(platforms) {
3835
+ return `No token file exists yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3836
+ }
2508
3837
  var errorText = (err) => err instanceof Error ? err.message : String(err);
2509
3838
  function runInit(cwd, flags, io2) {
2510
3839
  if (!flags.id) {
@@ -2518,9 +3847,24 @@ function runInit(cwd, flags, io2) {
2518
3847
  io2.err(errorText(err));
2519
3848
  return 1;
2520
3849
  }
3850
+ const fromFlags = platformsFromFlags(flags, io2);
3851
+ if (fromFlags === null) return 1;
3852
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, null);
3853
+ const outputs = defaultOutputs(platforms);
2521
3854
  const outDir = flags.out ?? DEFAULT_OUT_DIR;
2522
- writeConfig(cwd, { libraryId: flags.id, outDir, ...include ? { include } : {} });
2523
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
3855
+ writeConfig(cwd, {
3856
+ libraryId: flags.id,
3857
+ outDir,
3858
+ ...include ? { include } : {},
3859
+ ...platforms.length > 0 ? { platforms } : {},
3860
+ ...outputs.length > 0 ? { outputs } : {}
3861
+ });
3862
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
3863
+ for (const o of outputs) io2.out(`Token file for ${o.platform}: ${o.path} (${o.format}, ${o.case} names), written by the next pull.`);
3864
+ if (source === "flag" || source === "detected") {
3865
+ const missing = platformsMissingFormat(platforms);
3866
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
3867
+ }
2524
3868
  io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
2525
3869
  return 0;
2526
3870
  }
@@ -2544,7 +3888,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
2544
3888
  }
2545
3889
  function resolvedOutDir(cwd, flags, io2) {
2546
3890
  try {
2547
- return join5(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
3891
+ return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
2548
3892
  } catch (err) {
2549
3893
  io2.err(errorText(err));
2550
3894
  return null;
@@ -2580,16 +3924,22 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
2580
3924
  } catch {
2581
3925
  existing = null;
2582
3926
  }
3927
+ const fromFlags = platformsFromFlags(flags, io2);
3928
+ if (fromFlags === null) return 1;
2583
3929
  const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
2584
3930
  const keptInclude = include ?? existing?.include ?? null;
2585
3931
  const keptDtcg = existing?.dtcg ?? null;
3932
+ const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
3933
+ const outputs = withDefaults(existing?.outputs ?? [], platforms);
2586
3934
  writeConfig(cwd, {
2587
3935
  libraryId: flags.id,
2588
3936
  outDir,
2589
3937
  ...keptInclude ? { include: keptInclude } : {},
2590
- ...keptDtcg ? { dtcg: keptDtcg } : {}
3938
+ ...keptDtcg ? { dtcg: keptDtcg } : {},
3939
+ ...platforms.length > 0 ? { platforms } : {},
3940
+ ...existing?.outputs !== void 0 || outputs.length > 0 ? { outputs } : {}
2591
3941
  });
2592
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
3942
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
2593
3943
  const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
2594
3944
  switch (ignored.kind) {
2595
3945
  case "refused":
@@ -2627,7 +3977,14 @@ git rm --cached ${ignored.line}`);
2627
3977
  }
2628
3978
  const { replaced } = writeCredentials(cwd, { libraryId: flags.id, key });
2629
3979
  io2.out(replaced ? `Replaced the stored key in ${CREDENTIALS_NAME}.` : `Stored the pull key in ${CREDENTIALS_NAME}.`);
2630
- return runPull(cwd, { ...flags, key }, env, io2, fetcher);
3980
+ const code2 = await runPull(cwd, { ...flags, key }, env, io2, fetcher);
3981
+ if (code2 !== 0) return code2;
3982
+ const hosts = detectRepo(cwd).agents;
3983
+ io2.out("");
3984
+ io2.out("Next step for a coding agent: npx spec-layer skill --install");
3985
+ io2.out(hosts.length > 0 ? `That writes a guide to the pulled files, adapted to this codebase, to ${hosts.map((h) => installTarget(h).path).join(", ")}.` : `That writes a guide to the pulled files, adapted to this codebase, into ${installTarget("agents-md").path}; --agent ${AGENT_HOSTS.join("|")} chooses where.`);
3986
+ io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
3987
+ return 0;
2631
3988
  }
2632
3989
  async function runPull(cwd, flags, env, io2, fetcher) {
2633
3990
  const manifestAt = manifestReader();
@@ -2640,11 +3997,17 @@ async function runPull(cwd, flags, env, io2, fetcher) {
2640
3997
  io2.err(errorText(err));
2641
3998
  return 1;
2642
3999
  }
2643
- const manifest = manifestAt(join5(cwd, opts.outDir));
4000
+ const fromFlags = platformsFromFlags(flags, io2);
4001
+ if (fromFlags === null) return 1;
4002
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
4003
+ const outputs = outputsForRun(fromFlags, opts, platforms);
4004
+ const manifest = manifestAt(join7(cwd, opts.outDir));
4005
+ const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4006
+ const willWriteFoundation = selection.foundation && foundationOnDisk;
2644
4007
  const etag = manifest && sameOutput(
2645
- { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
2646
- { selection, dtcg: opts.dtcg }
2647
- ) ? manifest.bundleHash : void 0;
4008
+ { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs },
4009
+ { selection, dtcg: opts.dtcg, outputs }
4010
+ ) && (!willWriteFoundation || outputs.every((o) => existsSync8(resolve4(cwd, o.path)) && existsSync8(join7(cwd, opts.outDir, "outputs", `${outputId(o)}.map.json`)))) ? manifest.bundleHash : void 0;
2648
4011
  const result = await fetchBundle({
2649
4012
  api: opts.api,
2650
4013
  libraryId: opts.libraryId,
@@ -2661,11 +4024,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
2661
4024
  return 0;
2662
4025
  }
2663
4026
  let written;
4027
+ let outputPaths = [];
2664
4028
  try {
2665
4029
  const bundle = parseBundle(result.raw);
2666
4030
  const selected = selectComponents(bundle, selection);
2667
- written = writeBundleFiles({
2668
- outDir: join5(cwd, opts.outDir),
4031
+ const writeResult = writeBundleFiles({
4032
+ outDir: join7(cwd, opts.outDir),
2669
4033
  cwd,
2670
4034
  raw: result.raw,
2671
4035
  bundle,
@@ -2673,8 +4037,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
2673
4037
  libraryId: opts.libraryId,
2674
4038
  publishedAt: result.publishedAt,
2675
4039
  bundleHash: result.bundleHash,
2676
- dtcg: opts.dtcg
4040
+ dtcg: opts.dtcg,
4041
+ platforms,
4042
+ outputs
2677
4043
  });
4044
+ written = writeResult.written;
4045
+ outputPaths = writeResult.outputs;
2678
4046
  io2.out(
2679
4047
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
2680
4048
  );
@@ -2683,13 +4051,22 @@ async function runPull(cwd, flags, env, io2, fetcher) {
2683
4051
  return 1;
2684
4052
  }
2685
4053
  io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
4054
+ for (const path of outputPaths) {
4055
+ const o = outputs.find((x) => x.path === path);
4056
+ if (o) io2.out(`Wrote ${path} (${o.platform}/${o.format}, ${o.case} names).`);
4057
+ }
4058
+ if (outputPaths.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4059
+ if (source === "flag" || source === "config") {
4060
+ const missing = platformsMissingFormat(platforms);
4061
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
4062
+ }
2686
4063
  return 0;
2687
4064
  }
2688
4065
  async function runStatus(cwd, flags, env, io2, fetcher) {
2689
4066
  const manifestAt = manifestReader();
2690
4067
  const opts = resolved(cwd, flags, env, io2, manifestAt);
2691
4068
  if (!opts) return 1;
2692
- const manifest = manifestAt(join5(cwd, opts.outDir));
4069
+ const manifest = manifestAt(join7(cwd, opts.outDir));
2693
4070
  if (!manifest) {
2694
4071
  io2.err(NO_LOCAL_PULL);
2695
4072
  return 2;
@@ -2721,11 +4098,15 @@ function runList(cwd, flags, io2) {
2721
4098
  return 1;
2722
4099
  }
2723
4100
  io2.out(`Library ${manifest.libraryId}, published ${manifest.publishedAt}.`);
2724
- const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.aiPath ?? "not written", a.contentHash]);
4101
+ const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.path ?? "not written", a.contentHash]);
2725
4102
  const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
2726
4103
  for (const row of rows) {
2727
4104
  io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
2728
4105
  }
4106
+ for (const o of manifest.outputs ?? []) {
4107
+ const written = existsSync8(join7(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4108
+ io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
4109
+ }
2729
4110
  return 0;
2730
4111
  }
2731
4112
  var SHOW_USAGE = 'spec-layer show takes "foundation" or "component NAME".';
@@ -2775,20 +4156,99 @@ Available: ${available || "none"}.`);
2775
4156
  ` : entry2.ai);
2776
4157
  return 0;
2777
4158
  }
4159
+ function runTools(flags, io2) {
4160
+ if (flags.json) io2.write(toolsJson(cliVersion()));
4161
+ else io2.out(toolsText());
4162
+ return 0;
4163
+ }
4164
+ function collectSkillInput(cwd, flags, io2) {
4165
+ let config = null;
4166
+ try {
4167
+ config = readConfig(cwd);
4168
+ } catch (err) {
4169
+ io2.err(errorText(err));
4170
+ return null;
4171
+ }
4172
+ const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
4173
+ const profile = detectRepo(cwd);
4174
+ const fromFlags = platformsFromFlags(flags, io2);
4175
+ if (fromFlags === null) return null;
4176
+ const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
4177
+ const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
4178
+ return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
4179
+ }
4180
+ function skillHosts(flags, input, io2) {
4181
+ const named = flags.agent ?? [];
4182
+ if (named.length > 0) {
4183
+ const hosts = [];
4184
+ for (const value of named) {
4185
+ if (!isAgentHost(value)) {
4186
+ io2.err(`--agent takes ${AGENT_HOSTS.join(", ")}, not "${value}".`);
4187
+ return null;
4188
+ }
4189
+ if (!hosts.includes(value)) hosts.push(value);
4190
+ }
4191
+ return hosts;
4192
+ }
4193
+ return input.profile.agents.length > 0 ? input.profile.agents : ["agents-md"];
4194
+ }
4195
+ function runSkill(cwd, flags, io2) {
4196
+ const input = collectSkillInput(cwd, flags, io2);
4197
+ if (!input) return 1;
4198
+ const hosts = skillHosts(flags, input, io2);
4199
+ if (!hosts) return 1;
4200
+ if (flags.json) {
4201
+ io2.write(`${JSON.stringify({
4202
+ cli_version: input.version,
4203
+ detected: input.profile,
4204
+ platforms: input.platforms,
4205
+ platform_source: input.platformSource,
4206
+ pull: input.pull,
4207
+ install_targets: hosts.map((h) => installTarget(h))
4208
+ }, null, 2)}
4209
+ `);
4210
+ return 0;
4211
+ }
4212
+ const guide = buildSkillGuide(input);
4213
+ if (!flags.install) {
4214
+ io2.write(guide);
4215
+ return 0;
4216
+ }
4217
+ const chosen = flags.agent && flags.agent.length > 0 ? "named with --agent" : input.profile.agents.length > 0 ? "detected in this repository" : "the default when no agent is detected";
4218
+ for (const host of hosts) {
4219
+ let outcome;
4220
+ try {
4221
+ outcome = installSkill(cwd, host, guide);
4222
+ } catch (err) {
4223
+ io2.err(`Could not write ${installTarget(host).path}: ${errorText(err)}`);
4224
+ return 1;
4225
+ }
4226
+ const verb = outcome.result === "created" ? "Wrote" : outcome.result === "updated" ? "Updated" : "Unchanged:";
4227
+ io2.out(`${verb} ${outcome.path} (${host}, ${chosen}).`);
4228
+ }
4229
+ if (!input.pull) io2.out(`No local pull yet, so the guide lists no components. Run spec-layer pull, then spec-layer skill --install again.`);
4230
+ if (input.platformSource === "none") io2.out(`No target platform detected. Pass --platform ${PLATFORMS.join("|")} to write platform-specific token advice.`);
4231
+ return 0;
4232
+ }
2778
4233
 
2779
4234
  // src/cli.ts
2780
4235
  var USAGE = `spec-layer <command>
2781
4236
 
2782
4237
  Commands:
2783
- setup --id lib_... --key sl_... [--out DIR] [selection]
4238
+ setup --id lib_... --key sl_... [--out DIR] [selection] [--platform P]...
2784
4239
  store the key, then pull
2785
- init --id lib_... [--out DIR] [selection] write speclayer.json
2786
- pull [--id lib_...] [--key sl_...] [selection]
4240
+ init --id lib_... [--out DIR] [selection] [--platform P]...
4241
+ write speclayer.json
4242
+ pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
2787
4243
  fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
2788
4244
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
2789
4245
  list list every artifact in the last pull
2790
4246
  show foundation | component NAME [--canonical]
2791
4247
  print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
4248
+ tools [--json] list every command with what it reaches and writes
4249
+ skill [--install] [--agent HOST]... [--platform P]... [--json]
4250
+ print a guide for a coding agent, adapted to this repo and the last pull;
4251
+ --install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
2792
4252
 
2793
4253
  Selection (setup, pull and init; flags replace the include block in speclayer.json):
2794
4254
  --only foundation | components write just the foundation, or just components
@@ -2796,6 +4256,7 @@ Selection (setup, pull and init; flags replace the include block in speclayer.js
2796
4256
 
2797
4257
  Options:
2798
4258
  --api URL override the API origin (default https://api.spec-layer.com)
4259
+ --platform web|ios|android|flutter the target this repo builds for (repeatable); applies to setup, init, pull, and skill; setup and init store it, pull uses it for the run
2799
4260
  The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
2800
4261
  var io = {
2801
4262
  out: (l) => console.log(l),
@@ -2817,7 +4278,11 @@ async function main() {
2817
4278
  api: { type: "string" },
2818
4279
  only: { type: "string" },
2819
4280
  component: { type: "string", multiple: true },
2820
- canonical: { type: "boolean" }
4281
+ canonical: { type: "boolean" },
4282
+ json: { type: "boolean" },
4283
+ install: { type: "boolean" },
4284
+ agent: { type: "string", multiple: true },
4285
+ platform: { type: "string", multiple: true }
2821
4286
  }
2822
4287
  }));
2823
4288
  } catch {
@@ -2833,6 +4298,8 @@ async function main() {
2833
4298
  if (command === "status") return await runStatus(cwd, values, process.env, io);
2834
4299
  if (command === "list") return runList(cwd, values, io);
2835
4300
  if (command === "show") return runShow(cwd, values, positionals.slice(1), io);
4301
+ if (command === "tools") return runTools(values, io);
4302
+ if (command === "skill") return runSkill(cwd, values, io);
2836
4303
  io.err(USAGE);
2837
4304
  return 1;
2838
4305
  } catch (err) {