spec-layer 0.5.0 → 0.7.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 +138 -17
  2. package/dist/cli.js +1253 -319
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -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 join7 } from "node:path";
562
+ import { existsSync as existsSync8 } from "node:fs";
563
+ import { join as join8, resolve as resolve5 } from "node:path";
563
564
 
564
565
  // ../extractor/src/statesMatrix.ts
565
566
  var STATE_ORDER = [
@@ -1333,7 +1334,15 @@ function dtcgSegments(name) {
1333
1334
  }
1334
1335
  return { segments, notes };
1335
1336
  }
1336
- var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1337
+ function trimDashes(s) {
1338
+ let start = 0;
1339
+ let end = s.length;
1340
+ while (start < end && s[start] === "-") start += 1;
1341
+ while (end > start && s[end - 1] === "-") end -= 1;
1342
+ return s.slice(start, end);
1343
+ }
1344
+ var dtcgSlug = (s) => trimDashes(s.toLowerCase().replace(/[^a-z0-9]+/g, "-")) || "unnamed";
1345
+ var slug = dtcgSlug;
1337
1346
  var RESERVED_FILE_NAMES = [
1338
1347
  "styles.typography.json",
1339
1348
  "styles.effects.json",
@@ -1611,15 +1620,15 @@ function styleMember(p, property, scopes, path, name) {
1611
1620
  });
1612
1621
  return null;
1613
1622
  }
1614
- const converted = dtcgLiteral(property.resolved, scopes, p.options.values);
1615
- if ("omit" in converted) {
1616
- if (converted.omit === "unit_not_expressible") {
1623
+ const converted2 = dtcgLiteral(property.resolved, scopes, p.options.values);
1624
+ if ("omit" in converted2) {
1625
+ if (converted2.omit === "unit_not_expressible") {
1617
1626
  reportOnce(p, {
1618
1627
  code: "unit_not_expressible",
1619
1628
  severity: "info",
1620
1629
  path,
1621
1630
  message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
1622
- details: { property: name, ...converted.details }
1631
+ details: { property: name, ...converted2.details }
1623
1632
  });
1624
1633
  const d = property.resolved;
1625
1634
  return { extension: { value: d.number, unit: d.unit } };
@@ -1629,11 +1638,11 @@ function styleMember(p, property, scopes, path, name) {
1629
1638
  severity: "warning",
1630
1639
  path,
1631
1640
  message: `The ${name} property has a type DTCG cannot state and was omitted.`,
1632
- details: { property: name, ...converted.details }
1641
+ details: { property: name, ...converted2.details }
1633
1642
  });
1634
1643
  return null;
1635
1644
  }
1636
- return { value: converted.$value };
1645
+ return { value: converted2.$value };
1637
1646
  }
1638
1647
  var TYPOGRAPHY_MEMBERS = [
1639
1648
  ["font_family", "fontFamily", []],
@@ -1733,8 +1742,8 @@ function effectLeaf(p, style, path) {
1733
1742
  }
1734
1743
  const raw = effect[field];
1735
1744
  if (raw === void 0) continue;
1736
- const converted = dtcgLiteral(raw, [], p.options.values);
1737
- if (!("omit" in converted)) shadow[name] = converted.$value;
1745
+ const converted2 = dtcgLiteral(raw, [], p.options.values);
1746
+ if (!("omit" in converted2)) shadow[name] = converted2.$value;
1738
1747
  }
1739
1748
  shadow.inset = effect.type === "inner_shadow";
1740
1749
  shadows.push(shadow);
@@ -2000,7 +2009,7 @@ function tokenLeaf(p, token, collection, modeId) {
2000
2009
  }
2001
2010
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2002
2011
  }
2003
- const converted = projectedLiteral(p, token, value.value, (override) => {
2012
+ const converted2 = projectedLiteral(p, token, value.value, (override) => {
2004
2013
  reportOnce(p, {
2005
2014
  code: "unit_override_conflicts_with_scope",
2006
2015
  severity: "warning",
@@ -2009,18 +2018,18 @@ function tokenLeaf(p, token, collection, modeId) {
2009
2018
  details: { id: token.id, override, scopes: [...token.scopes] }
2010
2019
  });
2011
2020
  });
2012
- if ("omit" in converted) {
2021
+ if ("omit" in converted2) {
2013
2022
  reportOnce(p, {
2014
- code: converted.omit,
2023
+ code: converted2.omit,
2015
2024
  severity: "warning",
2016
2025
  path,
2017
2026
  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 }
2027
+ 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.`,
2028
+ details: { id: token.id, ...converted2.details }
2020
2029
  });
2021
2030
  return null;
2022
2031
  }
2023
- return { $type: converted.$type, $value: converted.$value, ...description };
2032
+ return { $type: converted2.$type, $value: converted2.$value, ...description };
2024
2033
  }
2025
2034
  function dtcgExportFiles(out) {
2026
2035
  const text = (v) => `${JSON.stringify(v, null, 2)}
@@ -2033,6 +2042,509 @@ function dtcgExportFiles(out) {
2033
2042
  return files;
2034
2043
  }
2035
2044
 
2045
+ // ../extractor/src/v5/outputs/naming.ts
2046
+ var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
2047
+ function splitWords(segment) {
2048
+ return segment.replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
2049
+ }
2050
+ function pathWords(path) {
2051
+ return path.split(".").flatMap((seg) => {
2052
+ const words = splitWords(seg);
2053
+ return words.length > 0 ? words : ["_"];
2054
+ });
2055
+ }
2056
+ function joinWords(words, nameCase) {
2057
+ const lower = words.map((w) => w.toLowerCase());
2058
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
2059
+ switch (nameCase) {
2060
+ case "kebab":
2061
+ return lower.join("-");
2062
+ case "snake":
2063
+ return lower.join("_");
2064
+ case "constant":
2065
+ return lower.join("_").toUpperCase();
2066
+ case "camel":
2067
+ return lower.map((w, i) => i === 0 ? w : cap(w)).join("");
2068
+ case "pascal":
2069
+ return lower.map(cap).join("");
2070
+ default: {
2071
+ const exhaustive = nameCase;
2072
+ return exhaustive;
2073
+ }
2074
+ }
2075
+ }
2076
+ function deriveName(path, nameCase) {
2077
+ return joinWords(pathWords(path), nameCase);
2078
+ }
2079
+ function sortReport(entries) {
2080
+ return [...entries].sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
2081
+ }
2082
+ function resolveNames(paths, meta, rules) {
2083
+ const report2 = [];
2084
+ const candidates = /* @__PURE__ */ new Map();
2085
+ for (const path of [...paths].sort(compareCodeUnits)) {
2086
+ let name = null;
2087
+ let source = "derived";
2088
+ const declared = rules.codeSyntaxKey ? meta[path]?.code_syntax?.[rules.codeSyntaxKey] : void 0;
2089
+ if (declared !== void 0) {
2090
+ name = rules.acceptDeclared(declared);
2091
+ if (name !== null) {
2092
+ source = "code_syntax";
2093
+ } else {
2094
+ report2.push({
2095
+ code: "code_syntax_not_usable",
2096
+ severity: "info",
2097
+ path,
2098
+ message: `The declared ${rules.codeSyntaxKey} identifier "${declared}" is not a name this format can use; the name was derived instead.`,
2099
+ details: { declared, platform: rules.codeSyntaxKey ?? "" }
2100
+ });
2101
+ }
2102
+ }
2103
+ if (name === null) name = rules.affix(deriveName(path, rules.nameCase));
2104
+ const list = candidates.get(name) ?? [];
2105
+ list.push({ path, source });
2106
+ candidates.set(name, list);
2107
+ }
2108
+ const names = /* @__PURE__ */ new Map();
2109
+ const map = {};
2110
+ for (const [name, list] of candidates) {
2111
+ if (list.length > 1) {
2112
+ const collided = list.map((c) => c.path).sort(compareCodeUnits);
2113
+ for (const c of list) {
2114
+ report2.push({
2115
+ code: "name_collision",
2116
+ severity: "error",
2117
+ path: c.path,
2118
+ message: `${list.length} tokens would share the name ${name}; all were omitted.`,
2119
+ details: { name, paths: collided }
2120
+ });
2121
+ }
2122
+ continue;
2123
+ }
2124
+ names.set(list[0].path, name);
2125
+ map[list[0].path] = { name, source: list[0].source };
2126
+ }
2127
+ const sortedMap = Object.fromEntries(Object.entries(map).sort(([a], [b]) => compareCodeUnits(a, b)));
2128
+ return { names, map: sortedMap, report: sortReport(report2) };
2129
+ }
2130
+
2131
+ // ../extractor/src/v5/outputs/css.ts
2132
+ var CSS_INDEX_FILE = "index.css";
2133
+ var CSS_DEFAULTS = {
2134
+ case: "kebab",
2135
+ root: ":root",
2136
+ modeSelector: '[data-theme="{mode}"]'
2137
+ };
2138
+ var CSS_HEADER_PREFIX = "/* Generated by spec-layer";
2139
+ var EXT = "com.spec-layer";
2140
+ var CUSTOM_PROPERTY = /^--[A-Za-z0-9_-]+$/;
2141
+ var BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
2142
+ function acceptCssDeclared(declared) {
2143
+ if (CUSTOM_PROPERTY.test(declared)) return declared;
2144
+ if (BARE_IDENT.test(declared)) return `--${declared}`;
2145
+ return null;
2146
+ }
2147
+ var asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
2148
+ function collectLeaves(tree, prefix, out) {
2149
+ const node = asRecord(tree);
2150
+ if (!node) return;
2151
+ if ("$value" in node) {
2152
+ const ours = asRecord(asRecord(node.$extensions)?.[EXT]);
2153
+ out.push({
2154
+ path: prefix.join("."),
2155
+ type: typeof node.$type === "string" ? node.$type : "",
2156
+ value: node.$value,
2157
+ ext: ours
2158
+ });
2159
+ return;
2160
+ }
2161
+ for (const key of Object.keys(node)) {
2162
+ if (key.startsWith("$")) continue;
2163
+ collectLeaves(node[key], [...prefix, key], out);
2164
+ }
2165
+ }
2166
+ var unpointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
2167
+ var refFile = (src) => {
2168
+ const r = asRecord(src);
2169
+ return r && typeof r.$ref === "string" ? r.$ref : null;
2170
+ };
2171
+ function sourcesOf(resolver) {
2172
+ const out = [];
2173
+ for (const { $ref } of resolver.resolutionOrder) {
2174
+ const set = /^#\/sets\/(.+)$/.exec($ref);
2175
+ const mod = /^#\/modifiers\/(.+)$/.exec($ref);
2176
+ if (set) {
2177
+ const label = unpointer(set[1]);
2178
+ for (const src of resolver.sets[label]?.sources ?? []) {
2179
+ const file = refFile(src);
2180
+ if (file) out.push({ collection: label, mode: null, file, isDefault: true });
2181
+ }
2182
+ } else if (mod) {
2183
+ const label = unpointer(mod[1]);
2184
+ const m = resolver.modifiers[label];
2185
+ if (!m) continue;
2186
+ for (const [context, srcs] of Object.entries(m.contexts)) {
2187
+ for (const src of srcs) {
2188
+ const file = refFile(src);
2189
+ if (file) out.push({ collection: label, mode: context, file, isDefault: context === m.default });
2190
+ }
2191
+ }
2192
+ }
2193
+ }
2194
+ return out;
2195
+ }
2196
+ var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
2197
+ var collectionSlug = (file) => file.split(".")[0];
2198
+ function cssFileNames(sources) {
2199
+ const taken = /* @__PURE__ */ new Set([CSS_INDEX_FILE]);
2200
+ const out = /* @__PURE__ */ new Map();
2201
+ for (const s of sources) {
2202
+ if (out.has(s.file)) continue;
2203
+ const base = s.mode === null ? dtcgSlug(s.collection) : `${dtcgSlug(s.collection)}.${modeSlug(s.file)}`;
2204
+ let candidate = `${base}.css`;
2205
+ let n = 1;
2206
+ while (taken.has(candidate)) {
2207
+ n += 1;
2208
+ candidate = `${base}-${n}.css`;
2209
+ }
2210
+ taken.add(candidate);
2211
+ out.set(s.file, candidate);
2212
+ }
2213
+ return out;
2214
+ }
2215
+ var REF = /^\{(.+)\}$/;
2216
+ function hexChannels(hex) {
2217
+ const at = (i) => parseInt(hex.slice(i, i + 2), 16);
2218
+ return [at(1), at(3), at(5)];
2219
+ }
2220
+ var quoteFamily = (f) => `"${f.replace(/["\\]/g, "\\$&")}"`;
2221
+ function report(ctx, entry2) {
2222
+ ctx.report.push({ ...entry2, path: ctx.path, ...ctx.mode !== void 0 ? { mode: ctx.mode } : {} });
2223
+ }
2224
+ function cssValue(ctx, type, value, property) {
2225
+ const member = property !== void 0 ? { property } : {};
2226
+ if (typeof value === "string") {
2227
+ const ref = REF.exec(value);
2228
+ if (ref) {
2229
+ const name = ctx.alive.has(ref[1]) ? ctx.names.get(ref[1]) : void 0;
2230
+ if (name !== void 0) return `var(${name})`;
2231
+ report(ctx, {
2232
+ code: "reference_target_omitted",
2233
+ severity: "warning",
2234
+ message: `References ${ref[1]}, which this output does not emit; the value was omitted.`,
2235
+ details: { target: ref[1], ...member }
2236
+ });
2237
+ return null;
2238
+ }
2239
+ }
2240
+ switch (type) {
2241
+ case "color": {
2242
+ if (typeof value === "string") return value;
2243
+ const c = asRecord(value);
2244
+ if (c && typeof c.hex === "string" && typeof c.alpha === "number") {
2245
+ if (c.alpha === 1) return c.hex;
2246
+ const [r, g, b] = hexChannels(c.hex);
2247
+ return `rgb(${r} ${g} ${b} / ${c.alpha})`;
2248
+ }
2249
+ break;
2250
+ }
2251
+ case "dimension":
2252
+ case "duration": {
2253
+ if (typeof value === "string") return value;
2254
+ const d = asRecord(value);
2255
+ if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
2256
+ break;
2257
+ }
2258
+ case "number":
2259
+ case "fontWeight":
2260
+ if (typeof value === "number") return String(value);
2261
+ break;
2262
+ case "cubicBezier":
2263
+ if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
2264
+ return `cubic-bezier(${value.join(", ")})`;
2265
+ }
2266
+ break;
2267
+ case "fontFamily":
2268
+ if (typeof value === "string") return quoteFamily(value);
2269
+ if (Array.isArray(value) && value.every((f) => typeof f === "string")) {
2270
+ return value.map(quoteFamily).join(", ");
2271
+ }
2272
+ break;
2273
+ default:
2274
+ break;
2275
+ }
2276
+ report(ctx, {
2277
+ code: "not_expressible",
2278
+ severity: "warning",
2279
+ message: `CSS has no form for this ${type || "untyped"} value; it was omitted.`,
2280
+ details: { type, value, ...member }
2281
+ });
2282
+ return null;
2283
+ }
2284
+ var TEXT_TRANSFORM = { original: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" };
2285
+ var TEXT_DECORATION = { none: "none", underline: "underline", strikethrough: "line-through" };
2286
+ var TYPOGRAPHY_MEMBERS2 = [
2287
+ ["fontFamily", "fontFamily"],
2288
+ ["fontSize", "dimension"],
2289
+ ["fontWeight", "fontWeight"],
2290
+ ["lineHeight", "number"],
2291
+ ["letterSpacing", "dimension"]
2292
+ ];
2293
+ var TEXT_MEMBERS = [
2294
+ ["textTransform", "textCase", TEXT_TRANSFORM],
2295
+ ["textDecoration", "textDecoration", TEXT_DECORATION]
2296
+ ];
2297
+ function typographyMemberKeys(value, ext) {
2298
+ const keys = [];
2299
+ for (const [key] of TYPOGRAPHY_MEMBERS2) {
2300
+ if (key in value) keys.push(key);
2301
+ }
2302
+ for (const key of ["lineHeight", "letterSpacing"]) {
2303
+ if (key in value) continue;
2304
+ const d = asRecord(ext[key]);
2305
+ if (d && typeof d.value === "number" && typeof d.unit === "string") keys.push(key);
2306
+ }
2307
+ for (const [key, extKey] of TEXT_MEMBERS) {
2308
+ if (typeof ext[extKey] === "string") keys.push(key);
2309
+ }
2310
+ return keys;
2311
+ }
2312
+ function converted(ctx, property, from, to) {
2313
+ report(ctx, {
2314
+ code: "value_converted",
2315
+ severity: "info",
2316
+ message: `${property} was restated from ${String(from.value)}${String(from.unit)} as ${to}.`,
2317
+ details: { property, from, to }
2318
+ });
2319
+ }
2320
+ function extensionDimension(ctx, ext, key, name, percentTo) {
2321
+ const d = asRecord(ext[key]);
2322
+ if (!d || typeof d.value !== "number" || typeof d.unit !== "string") return null;
2323
+ if (d.unit === "%") {
2324
+ const to = percentTo(canonicalNumber(d.value / 100));
2325
+ converted(ctx, key, d, to);
2326
+ return `${name}: ${to};`;
2327
+ }
2328
+ return `${name}: ${d.value}${d.unit};`;
2329
+ }
2330
+ function typographyDecls(ctx, leaf, names) {
2331
+ const decls = [];
2332
+ const declaredPaths = [];
2333
+ const value = asRecord(leaf.value) ?? {};
2334
+ const ext = leaf.ext ?? {};
2335
+ const nameFor = (key) => names.get(`${leaf.path}.${key}`);
2336
+ for (const [key, type] of TYPOGRAPHY_MEMBERS2) {
2337
+ if (!(key in value)) continue;
2338
+ const name = nameFor(key);
2339
+ if (name === void 0) continue;
2340
+ const css = cssValue(ctx, type, value[key], key);
2341
+ if (css !== null) {
2342
+ decls.push(`${name}: ${css};`);
2343
+ declaredPaths.push(`${leaf.path}.${key}`);
2344
+ }
2345
+ }
2346
+ if (!("lineHeight" in value)) {
2347
+ const name = nameFor("lineHeight");
2348
+ if (name !== void 0) {
2349
+ const d = extensionDimension(ctx, ext, "lineHeight", name, (n) => String(n));
2350
+ if (d) {
2351
+ decls.push(d);
2352
+ declaredPaths.push(`${leaf.path}.lineHeight`);
2353
+ }
2354
+ }
2355
+ }
2356
+ if (!("letterSpacing" in value)) {
2357
+ const name = nameFor("letterSpacing");
2358
+ if (name !== void 0) {
2359
+ const d = extensionDimension(ctx, ext, "letterSpacing", name, (n) => `${n}em`);
2360
+ if (d) {
2361
+ decls.push(d);
2362
+ declaredPaths.push(`${leaf.path}.letterSpacing`);
2363
+ }
2364
+ }
2365
+ }
2366
+ for (const [key, extKey, table] of TEXT_MEMBERS) {
2367
+ const raw = ext[extKey];
2368
+ if (typeof raw !== "string") continue;
2369
+ const name = nameFor(key);
2370
+ if (name === void 0) continue;
2371
+ const css = table[raw];
2372
+ if (css !== void 0) {
2373
+ decls.push(`${name}: ${css};`);
2374
+ declaredPaths.push(`${leaf.path}.${key}`);
2375
+ } else {
2376
+ report(ctx, {
2377
+ code: "not_expressible",
2378
+ severity: "info",
2379
+ message: `CSS text properties have no form for ${extKey} "${raw}"; it was omitted.`,
2380
+ details: { property: extKey, value: raw }
2381
+ });
2382
+ }
2383
+ }
2384
+ return { decls, declaredPaths };
2385
+ }
2386
+ var SHADOW_MEMBERS = [
2387
+ ["offsetX", "dimension"],
2388
+ ["offsetY", "dimension"],
2389
+ ["blur", "dimension"],
2390
+ ["spread", "dimension"],
2391
+ ["color", "color"]
2392
+ ];
2393
+ function shadowDecl(ctx, leaf, name) {
2394
+ const layers = Array.isArray(leaf.value) ? leaf.value : [];
2395
+ const omit = (reason, message) => {
2396
+ report(ctx, { code: "not_expressible", severity: "warning", message, details: { reason } });
2397
+ return null;
2398
+ };
2399
+ if (layers.length === 0) return omit("no_visible_shadow", "The style has no visible shadow, so no box-shadow was written.");
2400
+ const parts = [];
2401
+ for (const layer of layers) {
2402
+ const l = asRecord(layer);
2403
+ if (!l) return omit("layer_not_an_object", "A shadow layer is not an object; the style was omitted.");
2404
+ const members = [];
2405
+ for (const [key, type] of SHADOW_MEMBERS) {
2406
+ if (!(key in l)) return omit(`missing_${key}`, `A shadow layer has no ${key}, which box-shadow needs; the style was omitted.`);
2407
+ const css = cssValue(ctx, type, l[key], key);
2408
+ if (css === null) return null;
2409
+ members.push(css);
2410
+ }
2411
+ parts.push(`${l.inset === true ? "inset " : ""}${members.join(" ")}`);
2412
+ }
2413
+ return `${name}: ${parts.join(", ")};`;
2414
+ }
2415
+ var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
2416
+ function headerText(header, nameCase) {
2417
+ return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
2418
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
2419
+ }
2420
+ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2421
+ const entries = [];
2422
+ const declared = /* @__PURE__ */ new Set();
2423
+ const firstFile = /* @__PURE__ */ new Map();
2424
+ const blocks = /* @__PURE__ */ new Map();
2425
+ for (const s of sources) {
2426
+ const perCollection = modes?.[s.collection];
2427
+ const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2428
+ const decls = [];
2429
+ const declaredHere = [];
2430
+ for (const leaf of leavesByFile.get(s.file) ?? []) {
2431
+ const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2432
+ if (leaf.type === "typography") {
2433
+ const t = typographyDecls(ctx, leaf, names);
2434
+ decls.push(...t.decls);
2435
+ for (const p of t.declaredPaths) {
2436
+ declared.add(p);
2437
+ declaredHere.push(p);
2438
+ }
2439
+ } else {
2440
+ const name = names.get(leaf.path);
2441
+ if (name === void 0) continue;
2442
+ if (leaf.type === "shadow") {
2443
+ const d = shadowDecl(ctx, leaf, name);
2444
+ if (d !== null) {
2445
+ decls.push(d);
2446
+ declared.add(leaf.path);
2447
+ declaredHere.push(leaf.path);
2448
+ }
2449
+ } else {
2450
+ const v = cssValue(ctx, leaf.type, leaf.value);
2451
+ if (v !== null) {
2452
+ decls.push(`${name}: ${v};`);
2453
+ declared.add(leaf.path);
2454
+ declaredHere.push(leaf.path);
2455
+ }
2456
+ }
2457
+ }
2458
+ }
2459
+ if (decls.length === 0) continue;
2460
+ for (const p of declaredHere) if (!firstFile.has(p)) firstFile.set(p, s.file);
2461
+ const comment = `/* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`;
2462
+ const existing = blocks.get(s.file);
2463
+ if (existing) existing.decls.push(...decls);
2464
+ else blocks.set(s.file, { selector, comment, decls });
2465
+ }
2466
+ return { blocks, entries, declared, firstFile };
2467
+ }
2468
+ function cssOutput(exp, header, options = {}) {
2469
+ const nameCase = options.case ?? CSS_DEFAULTS.case;
2470
+ const root = options.root ?? CSS_DEFAULTS.root;
2471
+ const template = options.modeSelector ?? CSS_DEFAULTS.modeSelector;
2472
+ const sources = sourcesOf(exp.resolver);
2473
+ const leavesByFile = /* @__PURE__ */ new Map();
2474
+ for (const s of sources) {
2475
+ if (!leavesByFile.has(s.file)) {
2476
+ const out = [];
2477
+ collectLeaves(exp.files[s.file] ?? {}, [], out);
2478
+ leavesByFile.set(s.file, out);
2479
+ }
2480
+ }
2481
+ const candidatePaths = /* @__PURE__ */ new Set();
2482
+ for (const leaves of leavesByFile.values()) {
2483
+ for (const leaf of leaves) {
2484
+ if (leaf.type === "typography") {
2485
+ const value = asRecord(leaf.value) ?? {};
2486
+ const ext = leaf.ext ?? {};
2487
+ for (const key of typographyMemberKeys(value, ext)) candidatePaths.add(`${leaf.path}.${key}`);
2488
+ } else {
2489
+ candidatePaths.add(leaf.path);
2490
+ }
2491
+ }
2492
+ }
2493
+ const resolved2 = resolveNames([...candidatePaths], exp.meta, {
2494
+ codeSyntaxKey: "WEB",
2495
+ acceptDeclared: acceptCssDeclared,
2496
+ affix: (body) => `--${body}`,
2497
+ nameCase
2498
+ });
2499
+ const names = resolved2.names;
2500
+ const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
2501
+ let alive = new Set(names.keys());
2502
+ let pass = emit(alive);
2503
+ for (; ; ) {
2504
+ if (pass.declared.size === alive.size) break;
2505
+ alive = pass.declared;
2506
+ pass = emit(alive);
2507
+ }
2508
+ const fileNames = cssFileNames(sources);
2509
+ const map = {};
2510
+ for (const [path, entry2] of Object.entries(resolved2.map)) {
2511
+ const from = pass.firstFile.get(path);
2512
+ if (alive.has(path) && from !== void 0) map[path] = { ...entry2, file: fileNames.get(from) };
2513
+ }
2514
+ const entries = [...resolved2.report, ...pass.entries];
2515
+ const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
2516
+ if (shared.length > 1) {
2517
+ for (const collection of shared) {
2518
+ entries.push({
2519
+ code: "mode_selector_shared",
2520
+ severity: "warning",
2521
+ path: collection,
2522
+ 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.`,
2523
+ details: { modifiers: shared, selector: template }
2524
+ });
2525
+ }
2526
+ }
2527
+ const head = headerText(header, nameCase);
2528
+ const files = {};
2529
+ const imports = [];
2530
+ for (const [source, block] of pass.blocks) {
2531
+ const name = fileNames.get(source);
2532
+ files[name] = `${head}
2533
+
2534
+ ${block.selector} {
2535
+ ${block.comment}
2536
+ ${block.decls.map((d) => ` ${d}`).join("\n")}
2537
+ }
2538
+ `;
2539
+ imports.push(block.comment, `@import "./${name}";`);
2540
+ }
2541
+ if (imports.length > 0) files[CSS_INDEX_FILE] = `${head}
2542
+
2543
+ ${imports.join("\n")}
2544
+ `;
2545
+ return { files, map, report: sortReport(entries) };
2546
+ }
2547
+
2036
2548
  // ../extractor/src/v5/componentContext.ts
2037
2549
  var import_js_sha2563 = __toESM(require_sha256(), 1);
2038
2550
 
@@ -2101,6 +2613,9 @@ function parseLibraryBundle(input) {
2101
2613
  };
2102
2614
  }
2103
2615
 
2616
+ // ../extractor/src/libraryBundleHash.ts
2617
+ var import_js_sha2564 = __toESM(require_sha256(), 1);
2618
+
2104
2619
  // src/bundle.ts
2105
2620
  function parseBundle(raw) {
2106
2621
  try {
@@ -2119,57 +2634,377 @@ function parseBundle(raw) {
2119
2634
  }
2120
2635
  }
2121
2636
  }
2122
-
2123
- // 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";
2126
-
2127
- // src/credentials.ts
2128
- import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
2129
- import { join } from "node:path";
2130
- var CREDENTIALS_NAME = "speclayer.local.json";
2131
- var unreadable = () => new Error(
2132
- `${CREDENTIALS_NAME} cannot be read. Delete it, then run the setup command from the plugin's Library screen.`
2133
- );
2134
- function readCredentials(cwd) {
2135
- const path = join(cwd, CREDENTIALS_NAME);
2136
- if (!existsSync(path)) return null;
2137
- let parsed;
2637
+
2638
+ // src/config.ts
2639
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
2640
+ import { join as join4 } from "node:path";
2641
+
2642
+ // src/credentials.ts
2643
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
2644
+ import { join } from "node:path";
2645
+ var CREDENTIALS_NAME = "speclayer.local.json";
2646
+ var unreadable = () => new Error(
2647
+ `${CREDENTIALS_NAME} cannot be read. Delete it, then run the setup command from the plugin's Library screen.`
2648
+ );
2649
+ function readCredentials(cwd) {
2650
+ const path = join(cwd, CREDENTIALS_NAME);
2651
+ if (!existsSync(path)) return null;
2652
+ let parsed;
2653
+ try {
2654
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2655
+ } catch {
2656
+ throw unreadable();
2657
+ }
2658
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw unreadable();
2659
+ const record = parsed;
2660
+ if (typeof record.libraryId !== "string" || typeof record.key !== "string") throw unreadable();
2661
+ return { libraryId: record.libraryId, key: record.key };
2662
+ }
2663
+ function writeCredentials(cwd, stored) {
2664
+ const path = join(cwd, CREDENTIALS_NAME);
2665
+ const replaced = existsSync(path);
2666
+ const body = { libraryId: stored.libraryId, key: stored.key };
2667
+ writeFileSync(path, `${JSON.stringify(body, null, 2)}
2668
+ `, { mode: 384 });
2669
+ chmodSync(path, 384);
2670
+ return { replaced };
2671
+ }
2672
+
2673
+ // src/detect.ts
2674
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2675
+ import { join as join2 } from "node:path";
2676
+ var CODE_SYNTAX_KEY = {
2677
+ web: "WEB",
2678
+ ios: "iOS",
2679
+ android: "ANDROID",
2680
+ flutter: null
2681
+ };
2682
+ var PLATFORMS = ["web", "ios", "android", "flutter"];
2683
+ var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2684
+ var uniq = (xs) => [...new Set(xs)];
2685
+ function readPackageJson(cwd) {
2686
+ const path = join2(cwd, "package.json");
2687
+ if (!existsSync2(path)) return null;
2688
+ let parsed;
2689
+ try {
2690
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
2691
+ } catch {
2692
+ return null;
2693
+ }
2694
+ if (typeof parsed !== "object" || parsed === null) return null;
2695
+ const record = parsed;
2696
+ const deps = {};
2697
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2698
+ const block = record[field];
2699
+ if (typeof block !== "object" || block === null) continue;
2700
+ for (const [name, range] of Object.entries(block)) {
2701
+ if (typeof range === "string") deps[name] = range;
2702
+ }
2703
+ }
2704
+ return { deps };
2705
+ }
2706
+ function majorOf(range) {
2707
+ const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
2708
+ return m ? Number(m[1]) : null;
2709
+ }
2710
+ var DEP_SIGNALS = [
2711
+ { dep: "react", platform: "web", framework: "react" },
2712
+ { dep: "next", platform: "web", framework: "next" },
2713
+ { dep: "vue", platform: "web", framework: "vue" },
2714
+ { dep: "nuxt", platform: "web", framework: "nuxt" },
2715
+ { dep: "svelte", platform: "web", framework: "svelte" },
2716
+ { dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
2717
+ { dep: "@angular/core", platform: "web", framework: "angular" },
2718
+ { dep: "solid-js", platform: "web", framework: "solid" },
2719
+ { dep: "lit", platform: "web", framework: "lit" },
2720
+ { dep: "astro", platform: "web", framework: "astro" },
2721
+ { dep: "react-native", platform: "ios", framework: "react-native" },
2722
+ { dep: "expo", platform: "ios", framework: "expo" },
2723
+ { dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
2724
+ { dep: "styled-components", platform: "web", framework: "styled-components" },
2725
+ { dep: "@emotion/react", platform: "web", framework: "emotion" },
2726
+ { dep: "sass", platform: "web", framework: "sass" },
2727
+ { dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
2728
+ { dep: "@stitches/react", platform: "web", framework: "stitches" },
2729
+ { dep: "@pandacss/dev", platform: "web", framework: "panda" },
2730
+ { dep: "style-dictionary", tokenTool: "style-dictionary" },
2731
+ { dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
2732
+ { dep: "typescript", language: "typescript" }
2733
+ ];
2734
+ var FILE_SIGNALS = [
2735
+ { test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
2736
+ { test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
2737
+ { test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
2738
+ { test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
2739
+ { test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
2740
+ { test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
2741
+ { test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
2742
+ { test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
2743
+ { test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
2744
+ { test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
2745
+ { test: (n) => n === "go.mod", signal: "Go module", language: "go" },
2746
+ { test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
2747
+ { test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
2748
+ { test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
2749
+ { test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
2750
+ { test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
2751
+ { test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
2752
+ { test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
2753
+ { test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
2754
+ { test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
2755
+ { test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
2756
+ { test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
2757
+ { test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
2758
+ { test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
2759
+ ];
2760
+ function detectRepo(cwd) {
2761
+ const platforms = [];
2762
+ const languages = [];
2763
+ const frameworks = [];
2764
+ const tokenTools = [];
2765
+ const agents = [];
2766
+ const evidence = [];
2767
+ let styleDictionaryMajor = null;
2768
+ let names = [];
2769
+ try {
2770
+ names = readdirSync(cwd).sort();
2771
+ } catch {
2772
+ names = [];
2773
+ }
2774
+ for (const name of names) {
2775
+ for (const rule of FILE_SIGNALS) {
2776
+ if (!rule.test(name)) continue;
2777
+ evidence.push({ signal: rule.signal, file: name });
2778
+ if (rule.platform) platforms.push(rule.platform);
2779
+ if (rule.language) languages.push(rule.language);
2780
+ if (rule.framework) frameworks.push(rule.framework);
2781
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2782
+ if (rule.agent) agents.push(rule.agent);
2783
+ }
2784
+ }
2785
+ if (existsSync2(join2(cwd, ".github", "copilot-instructions.md")) || existsSync2(join2(cwd, ".github", "instructions"))) {
2786
+ evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
2787
+ agents.push("copilot");
2788
+ }
2789
+ const pkg = readPackageJson(cwd);
2790
+ if (pkg) {
2791
+ for (const rule of DEP_SIGNALS) {
2792
+ const range = pkg.deps[rule.dep];
2793
+ if (range === void 0) continue;
2794
+ evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
2795
+ if (rule.platform) platforms.push(rule.platform);
2796
+ if (rule.framework) frameworks.push(rule.framework);
2797
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2798
+ if (rule.language) languages.push(rule.language);
2799
+ if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
2800
+ }
2801
+ if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
2802
+ }
2803
+ const order = (p) => PLATFORMS.indexOf(p);
2804
+ const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
2805
+ return {
2806
+ platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
2807
+ languages: uniq(languages).sort(),
2808
+ frameworks: uniq(frameworks).sort(),
2809
+ tokenTools: uniq(tokenTools).sort(),
2810
+ agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
2811
+ styleDictionaryMajor,
2812
+ evidence
2813
+ };
2814
+ }
2815
+ function isPlatform(value) {
2816
+ return PLATFORMS.includes(value);
2817
+ }
2818
+ function isAgentHost(value) {
2819
+ return AGENT_HOSTS.includes(value);
2820
+ }
2821
+
2822
+ // src/outputs.ts
2823
+ import { readFileSync as readFileSync3 } from "node:fs";
2824
+ import { resolve as resolve2 } from "node:path";
2825
+
2826
+ // src/visibleDir.ts
2827
+ import {
2828
+ closeSync,
2829
+ existsSync as existsSync3,
2830
+ lstatSync,
2831
+ mkdirSync,
2832
+ openSync,
2833
+ readSync,
2834
+ readdirSync as readdirSync2,
2835
+ renameSync,
2836
+ rmSync,
2837
+ writeFileSync as writeFileSync2
2838
+ } from "node:fs";
2839
+ import { isAbsolute, join as join3, relative, resolve } from "node:path";
2840
+ var inside = (parent, child) => {
2841
+ const rel = relative(parent, child);
2842
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2843
+ };
2844
+ var isDotfile = (name) => name.startsWith(".");
2845
+ function carriesMarker(abs, marker) {
2846
+ const newlines = (marker.match(/\n/g) ?? []).length;
2847
+ const wantBytes = Buffer.byteLength(marker, "utf8") + newlines;
2848
+ const fd = openSync(abs, "r");
2849
+ try {
2850
+ const buf = Buffer.alloc(wantBytes);
2851
+ const read = readSync(fd, buf, 0, wantBytes, 0);
2852
+ const text = buf.subarray(0, read).toString("utf8").replace(/\r\n/g, "\n");
2853
+ return text.startsWith(marker);
2854
+ } finally {
2855
+ closeSync(fd);
2856
+ }
2857
+ }
2858
+ function visibleDirProblem(cwd, outDir, dir, marker, others, configKey) {
2859
+ const root = resolve(cwd);
2860
+ const abs = resolve(cwd, dir);
2861
+ if (!inside(root, abs) || abs === root) return `${dir} is outside this directory. Choose a path inside the repository.`;
2862
+ if (inside(resolve(cwd, outDir), abs)) return `${dir} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
2863
+ for (const other of others) {
2864
+ const otherAbs = resolve(cwd, other);
2865
+ if (inside(abs, otherAbs) || inside(otherAbs, abs)) return `${dir} and ${other} overlap. Give each output its own directory.`;
2866
+ }
2867
+ if (!existsSync3(abs)) return null;
2868
+ if (!lstatSync(abs).isDirectory()) return `${dir} exists and is not a directory. Choose another path or remove the file.`;
2869
+ const foreign = `${dir} holds files spec-layer did not write. Set ${configKey} in speclayer.json to another path, or move them.`;
2870
+ for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
2871
+ if (isDotfile(entry2.name)) continue;
2872
+ if (!entry2.isFile()) return foreign;
2873
+ try {
2874
+ if (!carriesMarker(join3(abs, entry2.name), marker)) return foreign;
2875
+ } catch {
2876
+ return `${dir}/${entry2.name} could not be read.`;
2877
+ }
2878
+ }
2879
+ return null;
2880
+ }
2881
+ function writeAtomically(abs, text) {
2882
+ const partial = `${abs}.partial`;
2883
+ writeFileSync2(partial, text);
2884
+ try {
2885
+ renameSync(partial, abs);
2886
+ } catch (err) {
2887
+ rmSync(partial, { force: true });
2888
+ throw err;
2889
+ }
2890
+ }
2891
+ function writeVisibleDir(cwd, dir, marker, files, last) {
2892
+ const abs = resolve(cwd, dir);
2893
+ const names = Object.keys(files).filter((n) => n !== last);
2894
+ if (last !== void 0 && last in files) names.push(last);
2895
+ if (names.length === 0 && !existsSync3(abs)) return [];
2896
+ mkdirSync(abs, { recursive: true });
2897
+ for (const name of names) writeAtomically(join3(abs, name), files[name]);
2898
+ const keep = new Set(names);
2899
+ for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
2900
+ if (isDotfile(entry2.name) || keep.has(entry2.name) || !entry2.isFile()) continue;
2901
+ const path = join3(abs, entry2.name);
2902
+ if (carriesMarker(path, marker)) rmSync(path);
2903
+ }
2904
+ return names;
2905
+ }
2906
+
2907
+ // src/outputs.ts
2908
+ var FORMATS = [
2909
+ { platform: "web", format: "css", defaultPath: "tokens", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2910
+ ];
2911
+ var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
2912
+ var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
2913
+ function defaultOutputs(platforms) {
2914
+ 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 }));
2915
+ }
2916
+ function withDefaults(existing, platforms) {
2917
+ const covered = new Set(existing.map((o) => o.platform));
2918
+ return [...existing, ...defaultOutputs(platforms.filter((p) => !covered.has(p)))];
2919
+ }
2920
+ var outputId = (o) => `${o.platform}-${o.format}`;
2921
+ function parseOutput(value, index) {
2922
+ const at = `speclayer.json outputs[${index}]`;
2923
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${at} must be an object.`);
2924
+ const r = value;
2925
+ if (typeof r.platform !== "string" || typeof r.format !== "string") {
2926
+ throw new Error(`${at} needs "platform" and "format". Known: ${knownFormats()}.`);
2927
+ }
2928
+ const spec = specOf(r.platform, r.format);
2929
+ if (!spec) throw new Error(`${at}: unknown platform/format "${r.platform}/${r.format}". Known: ${knownFormats()}.`);
2930
+ const str = (key) => {
2931
+ if (r[key] !== void 0 && typeof r[key] !== "string") throw new Error(`${at} "${key}" must be a string.`);
2932
+ return r[key];
2933
+ };
2934
+ const path = str("path") ?? spec.defaultPath;
2935
+ if (path === null) throw new Error(`${at} needs "path": ${spec.platform} has no default location.`);
2936
+ const nameCase = str("case");
2937
+ if (nameCase !== void 0 && !NAME_CASES.includes(nameCase)) {
2938
+ throw new Error(`${at} "case" takes ${NAME_CASES.join(", ")}.`);
2939
+ }
2940
+ const root = str("root");
2941
+ const modeSelector = str("modeSelector");
2942
+ let modes;
2943
+ if (r.modes !== void 0) {
2944
+ const m = r.modes;
2945
+ if (typeof m !== "object" || m === null || Array.isArray(m) || !Object.values(m).every((v) => typeof v === "string")) {
2946
+ throw new Error(`${at} "modes" must map collection names to selector strings.`);
2947
+ }
2948
+ modes = m;
2949
+ }
2950
+ return {
2951
+ platform: spec.platform,
2952
+ format: spec.format,
2953
+ path,
2954
+ case: nameCase ?? spec.defaultCase,
2955
+ ...root !== void 0 ? { root } : {},
2956
+ ...modeSelector !== void 0 ? { modeSelector } : {},
2957
+ ...modes ? { modes } : {}
2958
+ };
2959
+ }
2960
+ function renderOutput(exp, o, header) {
2961
+ switch (o.format) {
2962
+ case "css":
2963
+ return cssOutput(exp, { ...header, platform: o.platform, format: o.format }, {
2964
+ case: o.case,
2965
+ ...o.root !== void 0 ? { root: o.root } : {},
2966
+ ...o.modeSelector !== void 0 ? { modeSelector: o.modeSelector } : {},
2967
+ ...o.modes ? { modes: o.modes } : {}
2968
+ });
2969
+ default: {
2970
+ const exhaustive = o.format;
2971
+ return exhaustive;
2972
+ }
2973
+ }
2974
+ }
2975
+ function readIndexImports(cwd, o) {
2976
+ const path = resolve2(cwd, o.path, CSS_INDEX_FILE);
2977
+ let text;
2138
2978
  try {
2139
- parsed = JSON.parse(readFileSync(path, "utf8"));
2979
+ text = readFileSync3(path, "utf8");
2140
2980
  } catch {
2141
- throw unreadable();
2981
+ return null;
2142
2982
  }
2143
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw unreadable();
2144
- const record = parsed;
2145
- if (typeof record.libraryId !== "string" || typeof record.key !== "string") throw unreadable();
2146
- return { libraryId: record.libraryId, key: record.key };
2983
+ return [...text.matchAll(/^@import "\.\/([^"\n]+)";$/gm)].map((m) => m[1]);
2147
2984
  }
2148
- function writeCredentials(cwd, stored) {
2149
- const path = join(cwd, CREDENTIALS_NAME);
2150
- const replaced = existsSync(path);
2151
- const body = { libraryId: stored.libraryId, key: stored.key };
2152
- writeFileSync(path, `${JSON.stringify(body, null, 2)}
2153
- `, { mode: 384 });
2154
- chmodSync(path, 384);
2155
- return { replaced };
2985
+ var LEGACY_CSS_PATH_NOTE = (path) => `${path} names a file; spec-layer 0.7.0 writes a directory. Set outputs[].path to a directory, for example "tokens", delete the old file, and pull again.`;
2986
+ function outputPathProblem(cwd, outDir, o, others = []) {
2987
+ if (/\.css$/i.test(o.path)) return LEGACY_CSS_PATH_NOTE(o.path);
2988
+ const marker = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
2989
+ return visibleDirProblem(cwd, outDir, o.path, marker, others, "outputs[].path");
2156
2990
  }
2157
2991
 
2158
2992
  // src/config.ts
2159
2993
  var DEFAULT_API = "https://api.spec-layer.com";
2160
2994
  var DEFAULT_OUT_DIR = ".speclayer";
2995
+ var DEFAULT_COMPONENT_SPECS_DIR = "component-specs";
2161
2996
  var CONFIG_NAME = "speclayer.json";
2162
2997
  var invalidConfig = () => new Error(`${CONFIG_NAME} is not valid JSON. Fix or delete it, then retry.`);
2163
2998
  function parseInclude(value) {
2164
2999
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
2165
3000
  const record = value;
2166
3001
  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"))) {
3002
+ if (record.components !== void 0 && record.components !== null && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
2168
3003
  throw invalidConfig();
2169
3004
  }
2170
3005
  return {
2171
3006
  foundation: record.foundation === void 0 ? true : record.foundation,
2172
- components: record.components === void 0 ? null : record.components
3007
+ components: record.components ?? null
2173
3008
  };
2174
3009
  }
2175
3010
  function parseDtcg(value) {
@@ -2189,12 +3024,39 @@ function parseDtcg(value) {
2189
3024
  }
2190
3025
  return out;
2191
3026
  }
3027
+ function parseComponentSpecsDir(value) {
3028
+ if (typeof value !== "string" || value.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
3029
+ let dir = value.replace(/\\/g, "/");
3030
+ if (dir.startsWith("./")) dir = dir.slice(2);
3031
+ dir = dir.replace(/\/+$/, "");
3032
+ if (dir.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
3033
+ return dir;
3034
+ }
3035
+ function parsePlatforms(value) {
3036
+ if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
3037
+ throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
3038
+ }
3039
+ return [...new Set(value)];
3040
+ }
3041
+ function parseOutputs(value) {
3042
+ if (!Array.isArray(value)) throw new Error('speclayer.json "outputs" must be an array.');
3043
+ const outputs = value.map((v, i) => parseOutput(v, i));
3044
+ const seen = /* @__PURE__ */ new Set();
3045
+ for (const output of outputs) {
3046
+ const key = `${output.platform}/${output.format}`;
3047
+ if (seen.has(key)) {
3048
+ throw new Error(`speclayer.json "outputs" lists ${output.platform}/${output.format} more than once. Keep one entry per platform and format.`);
3049
+ }
3050
+ seen.add(key);
3051
+ }
3052
+ return outputs;
3053
+ }
2192
3054
  function readConfig(cwd) {
2193
- const path = join2(cwd, CONFIG_NAME);
2194
- if (!existsSync2(path)) return null;
3055
+ const path = join4(cwd, CONFIG_NAME);
3056
+ if (!existsSync4(path)) return null;
2195
3057
  let parsed;
2196
3058
  try {
2197
- parsed = JSON.parse(readFileSync2(path, "utf8"));
3059
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2198
3060
  } catch {
2199
3061
  throw invalidConfig();
2200
3062
  }
@@ -2203,24 +3065,30 @@ function readConfig(cwd) {
2203
3065
  return {
2204
3066
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
2205
3067
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
3068
+ ...record.componentSpecsDir !== void 0 ? { componentSpecsDir: parseComponentSpecsDir(record.componentSpecsDir) } : {},
2206
3069
  ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2207
- ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
3070
+ ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
3071
+ ...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
3072
+ ...record.outputs !== void 0 ? { outputs: parseOutputs(record.outputs) } : {}
2208
3073
  };
2209
3074
  }
2210
3075
  function writeConfig(cwd, config) {
2211
3076
  const body = {
2212
3077
  libraryId: config.libraryId,
2213
3078
  outDir: config.outDir,
3079
+ ...config.componentSpecsDir ? { componentSpecsDir: config.componentSpecsDir } : {},
2214
3080
  ...config.include ? { include: config.include } : {},
2215
- ...config.dtcg ? { dtcg: config.dtcg } : {}
3081
+ ...config.dtcg ? { dtcg: config.dtcg } : {},
3082
+ ...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
3083
+ ...config.outputs ? { outputs: config.outputs } : {}
2216
3084
  };
2217
- writeFileSync2(join2(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
3085
+ writeFileSync3(join4(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2218
3086
  `);
2219
3087
  }
2220
3088
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
2221
3089
  const config = readConfig(cwd);
2222
3090
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
2223
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join2(cwd, outDir));
3091
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join4(cwd, outDir));
2224
3092
  const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2225
3093
  let storedKey = null;
2226
3094
  let storedKeyFor;
@@ -2234,11 +3102,14 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
2234
3102
  return {
2235
3103
  libraryId,
2236
3104
  outDir,
3105
+ componentSpecsDir: config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
2237
3106
  // A trailing slash would build "//v1/..." paths the proxy router 404s on.
2238
3107
  api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
2239
3108
  key: supplied ?? storedKey,
2240
3109
  ...config?.include ? { include: config.include } : {},
2241
3110
  ...config?.dtcg ? { dtcg: config.dtcg } : {},
3111
+ ...config?.platforms ? { platforms: config.platforms } : {},
3112
+ ...config?.outputs ? { outputs: config.outputs } : {},
2242
3113
  ...storedKeyFor ? { storedKeyFor } : {}
2243
3114
  };
2244
3115
  }
@@ -2277,8 +3148,8 @@ async function fetchBundle(opts) {
2277
3148
  }
2278
3149
 
2279
3150
  // 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";
3151
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3152
+ import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
2282
3153
 
2283
3154
  // src/selection.ts
2284
3155
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -2316,20 +3187,29 @@ function slugify(name) {
2316
3187
  const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2317
3188
  return slug2 || "component";
2318
3189
  }
3190
+ var COMPONENT_SPEC_MARKER = "spec_layer:\n kind: component";
2319
3191
  function readManifest(outDir) {
2320
- const path = join3(outDir, "manifest.json");
2321
- if (!existsSync3(path)) return null;
3192
+ const path = join5(outDir, "manifest.json");
3193
+ if (!existsSync5(path)) return null;
2322
3194
  try {
2323
- return JSON.parse(readFileSync3(path, "utf8"));
3195
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3196
+ parsed.artifacts = parsed.artifacts.map((artifact) => {
3197
+ const { aiPath, ...rest } = artifact;
3198
+ return {
3199
+ ...rest,
3200
+ path: rest.path ?? aiPath ?? null
3201
+ };
3202
+ });
3203
+ return parsed;
2324
3204
  } catch {
2325
3205
  return null;
2326
3206
  }
2327
3207
  }
2328
3208
  function readLocalBundle(outDir) {
2329
- const path = join3(outDir, "bundle.json");
2330
- if (!existsSync3(path)) return null;
3209
+ const path = join5(outDir, "bundle.json");
3210
+ if (!existsSync5(path)) return null;
2331
3211
  try {
2332
- return parseBundle(readFileSync3(path, "utf8"));
3212
+ return parseBundle(readFileSync5(path, "utf8"));
2333
3213
  } catch {
2334
3214
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
2335
3215
  }
@@ -2356,11 +3236,11 @@ function componentSlugs(bundle) {
2356
3236
  });
2357
3237
  }
2358
3238
  function assertReplaceable(outDir, cwd) {
2359
- const rel = relative(resolve(cwd), resolve(outDir));
2360
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
3239
+ const rel = relative2(resolve3(cwd), resolve3(outDir));
3240
+ if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
2361
3241
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
2362
3242
  }
2363
- if (existsSync3(outDir) && !existsSync3(join3(outDir, "manifest.json")) && readdirSync(outDir).length > 0) {
3243
+ if (existsSync5(outDir) && !existsSync5(join5(outDir, "manifest.json")) && readdirSync3(outDir).length > 0) {
2364
3244
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
2365
3245
  }
2366
3246
  }
@@ -2369,44 +3249,70 @@ function writeBundleFiles(opts) {
2369
3249
  const selection = opts.selection ?? DEFAULT_SELECTION;
2370
3250
  const selected = selectComponents(opts.bundle, selection);
2371
3251
  const slugs = componentSlugs(opts.bundle);
3252
+ const outputs = opts.outputs ?? [];
3253
+ const componentSpecsDir = opts.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3254
+ const outDirRel = relative2(resolve3(opts.cwd), resolve3(opts.outDir)).split(sep).join("/");
3255
+ const outputPaths = outputs.map((o) => o.path);
3256
+ const specsProblem = visibleDirProblem(opts.cwd, outDirRel, componentSpecsDir, COMPONENT_SPEC_MARKER, outputPaths, "componentSpecsDir");
3257
+ if (specsProblem) throw new Error(specsProblem);
3258
+ for (const o of outputs) {
3259
+ const problem = outputPathProblem(opts.cwd, outDirRel, o, [componentSpecsDir, ...outputPaths.filter((p) => p !== o.path)]);
3260
+ if (problem) throw new Error(problem);
3261
+ }
3262
+ const briefs = {};
3263
+ opts.bundle.components.forEach((component, i) => {
3264
+ if (!selected[i]) return;
3265
+ if (!component.ai.startsWith(COMPONENT_SPEC_MARKER)) {
3266
+ throw new Error(`The published brief for ${component.name} does not begin with the Spec Layer marker. Republish from the plugin, then pull again.`);
3267
+ }
3268
+ briefs[`${slugs[i]}.yaml`] = component.ai;
3269
+ });
2372
3270
  const staging = `${opts.outDir}.partial`;
2373
- rmSync(staging, { recursive: true, force: true });
3271
+ rmSync2(staging, { recursive: true, force: true });
2374
3272
  const written = [];
3273
+ const deliverables = [];
3274
+ const json = (v) => `${JSON.stringify(v, null, 2)}
3275
+ `;
2375
3276
  const put = (rel, content) => {
2376
- const path = join3(staging, rel);
2377
- mkdirSync(dirname(path), { recursive: true });
2378
- writeFileSync3(path, content);
3277
+ const path = join5(staging, rel);
3278
+ mkdirSync2(dirname(path), { recursive: true });
3279
+ writeFileSync4(path, content);
2379
3280
  written.push(rel);
2380
3281
  };
2381
3282
  try {
2382
3283
  put("bundle.json", opts.raw);
2383
3284
  const artifacts = [];
2384
3285
  if (opts.bundle.foundation) {
2385
- let aiPath = null;
3286
+ let path = null;
2386
3287
  if (selection.foundation) {
2387
3288
  const artifact = opts.bundle.foundation.artifact;
2388
3289
  if (validateLevel1(artifact).some((d) => d.severity === "error")) {
2389
3290
  throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
2390
3291
  }
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";
3292
+ const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3293
+ for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3294
+ path = `${outDirRel}/tokens/resolver.json`;
3295
+ const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
3296
+ for (const output of outputs) {
3297
+ const rendered = renderOutput(exp, output, header);
3298
+ put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
3299
+ put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
3300
+ deliverables.push({ output, files: rendered.files });
3301
+ }
2394
3302
  }
2395
3303
  artifacts.push({
2396
3304
  kind: "foundation",
2397
3305
  name: "foundation",
2398
3306
  contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash,
2399
- aiPath
3307
+ path
2400
3308
  });
2401
3309
  }
2402
3310
  opts.bundle.components.forEach((component, i) => {
2403
- const aiPath = selected[i] ? `ai/components/${slugs[i]}.yaml` : null;
2404
- if (aiPath) put(aiPath, component.ai);
2405
3311
  artifacts.push({
2406
3312
  kind: "component",
2407
3313
  name: component.name,
2408
3314
  contentHash: component.artifact.spec_layer.export.content_hash,
2409
- aiPath
3315
+ path: selected[i] ? `${componentSpecsDir}/${slugs[i]}.yaml` : null
2410
3316
  });
2411
3317
  });
2412
3318
  const manifest = {
@@ -2416,24 +3322,31 @@ function writeBundleFiles(opts) {
2416
3322
  pluginVersion: opts.bundle.pluginVersion,
2417
3323
  extractorVersion: opts.bundle.extractorVersion,
2418
3324
  selection,
3325
+ componentSpecsDir,
2419
3326
  artifacts,
2420
- ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
3327
+ ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
3328
+ ...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
3329
+ ...opts.outputs ? { outputs: opts.outputs } : {}
2421
3330
  };
2422
- put("manifest.json", `${JSON.stringify(manifest, null, 2)}
2423
- `);
3331
+ put("manifest.json", json(manifest));
2424
3332
  } catch (err) {
2425
- rmSync(staging, { recursive: true, force: true });
3333
+ rmSync2(staging, { recursive: true, force: true });
2426
3334
  throw err;
2427
3335
  }
2428
- rmSync(opts.outDir, { recursive: true, force: true });
2429
- renameSync(staging, opts.outDir);
2430
- return written;
3336
+ rmSync2(opts.outDir, { recursive: true, force: true });
3337
+ renameSync2(staging, opts.outDir);
3338
+ const componentSpecs = { path: componentSpecsDir, files: writeVisibleDir(opts.cwd, componentSpecsDir, COMPONENT_SPEC_MARKER, briefs) };
3339
+ const outputResults = [];
3340
+ for (const d of deliverables) {
3341
+ outputResults.push({ path: d.output.path, files: writeVisibleDir(opts.cwd, d.output.path, CSS_HEADER_PREFIX, d.files, CSS_INDEX_FILE) });
3342
+ }
3343
+ return { written, componentSpecs, outputs: outputResults };
2431
3344
  }
2432
3345
 
2433
3346
  // src/gitignore.ts
2434
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "node:fs";
3347
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
2435
3348
  import { spawnSync } from "node:child_process";
2436
- import { join as join4, dirname as dirname2, resolve as resolve2 } from "node:path";
3349
+ import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
2437
3350
  var COMMENT = "# Spec Layer pull key, not for committing";
2438
3351
  function git(cwd, args) {
2439
3352
  const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
@@ -2441,9 +3354,9 @@ function git(cwd, args) {
2441
3354
  return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
2442
3355
  }
2443
3356
  function insideWorkTreeWithoutGit(cwd) {
2444
- let dir = resolve2(cwd);
3357
+ let dir = resolve4(cwd);
2445
3358
  for (; ; ) {
2446
- if (existsSync4(join4(dir, ".git"))) return true;
3359
+ if (existsSync6(join6(dir, ".git"))) return true;
2447
3360
  const parent = dirname2(dir);
2448
3361
  if (parent === dir) return false;
2449
3362
  dir = parent;
@@ -2460,18 +3373,18 @@ function ensureIgnored(cwd, fileName) {
2460
3373
  if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
2461
3374
  const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
2462
3375
  if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
2463
- const path = join4(cwd, ".gitignore");
2464
- const existed = existsSync4(path);
3376
+ const path = join6(cwd, ".gitignore");
3377
+ const existed = existsSync6(path);
2465
3378
  try {
2466
3379
  if (!existed) {
2467
- writeFileSync4(path, `${COMMENT}
3380
+ writeFileSync5(path, `${COMMENT}
2468
3381
  ${fileName}
2469
3382
  `);
2470
3383
  } else {
2471
- const body = readFileSync4(path, "utf8");
3384
+ const body = readFileSync6(path, "utf8");
2472
3385
  if (!hasEntryLine(body, fileName)) {
2473
3386
  const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
2474
- writeFileSync4(path, `${body}${lead}${COMMENT}
3387
+ writeFileSync5(path, `${body}${lead}${COMMENT}
2475
3388
  ${fileName}
2476
3389
  `);
2477
3390
  }
@@ -2484,158 +3397,9 @@ ${fileName}
2484
3397
  return existed ? { kind: "added" } : { kind: "created" };
2485
3398
  }
2486
3399
 
2487
- // src/detect.ts
2488
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
2489
- import { join as join5 } from "node:path";
2490
- var CODE_SYNTAX_KEY = {
2491
- web: "WEB",
2492
- ios: "iOS",
2493
- android: "ANDROID",
2494
- flutter: null
2495
- };
2496
- var PLATFORMS = ["web", "ios", "android", "flutter"];
2497
- var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2498
- var uniq = (xs) => [...new Set(xs)];
2499
- function readPackageJson(cwd) {
2500
- const path = join5(cwd, "package.json");
2501
- if (!existsSync5(path)) return null;
2502
- let parsed;
2503
- try {
2504
- parsed = JSON.parse(readFileSync5(path, "utf8"));
2505
- } catch {
2506
- return null;
2507
- }
2508
- if (typeof parsed !== "object" || parsed === null) return null;
2509
- const record = parsed;
2510
- const deps = {};
2511
- for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2512
- const block = record[field];
2513
- if (typeof block !== "object" || block === null) continue;
2514
- for (const [name, range] of Object.entries(block)) {
2515
- if (typeof range === "string") deps[name] = range;
2516
- }
2517
- }
2518
- return { deps };
2519
- }
2520
- function majorOf(range) {
2521
- const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
2522
- return m ? Number(m[1]) : null;
2523
- }
2524
- var DEP_SIGNALS = [
2525
- { dep: "react", platform: "web", framework: "react" },
2526
- { dep: "next", platform: "web", framework: "next" },
2527
- { dep: "vue", platform: "web", framework: "vue" },
2528
- { dep: "nuxt", platform: "web", framework: "nuxt" },
2529
- { dep: "svelte", platform: "web", framework: "svelte" },
2530
- { dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
2531
- { dep: "@angular/core", platform: "web", framework: "angular" },
2532
- { dep: "solid-js", platform: "web", framework: "solid" },
2533
- { dep: "lit", platform: "web", framework: "lit" },
2534
- { dep: "astro", platform: "web", framework: "astro" },
2535
- { dep: "react-native", platform: "ios", framework: "react-native" },
2536
- { dep: "expo", platform: "ios", framework: "expo" },
2537
- { dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
2538
- { dep: "styled-components", platform: "web", framework: "styled-components" },
2539
- { dep: "@emotion/react", platform: "web", framework: "emotion" },
2540
- { dep: "sass", platform: "web", framework: "sass" },
2541
- { dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
2542
- { dep: "@stitches/react", platform: "web", framework: "stitches" },
2543
- { dep: "@pandacss/dev", platform: "web", framework: "panda" },
2544
- { dep: "style-dictionary", tokenTool: "style-dictionary" },
2545
- { dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
2546
- { dep: "typescript", language: "typescript" }
2547
- ];
2548
- var FILE_SIGNALS = [
2549
- { test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
2550
- { test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
2551
- { test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
2552
- { test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
2553
- { test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
2554
- { test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
2555
- { test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
2556
- { test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
2557
- { test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
2558
- { test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
2559
- { test: (n) => n === "go.mod", signal: "Go module", language: "go" },
2560
- { test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
2561
- { test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
2562
- { test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
2563
- { test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
2564
- { test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
2565
- { test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
2566
- { test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
2567
- { test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
2568
- { test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
2569
- { test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
2570
- { test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
2571
- { test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
2572
- { test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
2573
- ];
2574
- function detectRepo(cwd) {
2575
- const platforms = [];
2576
- const languages = [];
2577
- const frameworks = [];
2578
- const tokenTools = [];
2579
- const agents = [];
2580
- const evidence = [];
2581
- let styleDictionaryMajor = null;
2582
- let names = [];
2583
- try {
2584
- names = readdirSync2(cwd).sort();
2585
- } catch {
2586
- names = [];
2587
- }
2588
- for (const name of names) {
2589
- for (const rule of FILE_SIGNALS) {
2590
- if (!rule.test(name)) continue;
2591
- evidence.push({ signal: rule.signal, file: name });
2592
- if (rule.platform) platforms.push(rule.platform);
2593
- if (rule.language) languages.push(rule.language);
2594
- if (rule.framework) frameworks.push(rule.framework);
2595
- if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2596
- if (rule.agent) agents.push(rule.agent);
2597
- }
2598
- }
2599
- if (existsSync5(join5(cwd, ".github", "copilot-instructions.md")) || existsSync5(join5(cwd, ".github", "instructions"))) {
2600
- evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
2601
- agents.push("copilot");
2602
- }
2603
- const pkg = readPackageJson(cwd);
2604
- if (pkg) {
2605
- for (const rule of DEP_SIGNALS) {
2606
- const range = pkg.deps[rule.dep];
2607
- if (range === void 0) continue;
2608
- evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
2609
- if (rule.platform) platforms.push(rule.platform);
2610
- if (rule.framework) frameworks.push(rule.framework);
2611
- if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2612
- if (rule.language) languages.push(rule.language);
2613
- if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
2614
- }
2615
- if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
2616
- }
2617
- const order = (p) => PLATFORMS.indexOf(p);
2618
- const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
2619
- return {
2620
- platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
2621
- languages: uniq(languages).sort(),
2622
- frameworks: uniq(frameworks).sort(),
2623
- tokenTools: uniq(tokenTools).sort(),
2624
- agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
2625
- styleDictionaryMajor,
2626
- evidence
2627
- };
2628
- }
2629
- function isPlatform(value) {
2630
- return PLATFORMS.includes(value);
2631
- }
2632
- function isAgentHost(value) {
2633
- return AGENT_HOSTS.includes(value);
2634
- }
2635
-
2636
3400
  // src/skill.ts
2637
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
2638
- import { dirname as dirname3, join as join6 } from "node:path";
3401
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3402
+ import { dirname as dirname3, join as join7 } from "node:path";
2639
3403
 
2640
3404
  // src/tools.ts
2641
3405
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
@@ -2643,18 +3407,25 @@ var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
2643
3407
  var TOOLS = [
2644
3408
  {
2645
3409
  name: "setup",
2646
- usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--only foundation|components] [--component NAME]...",
3410
+ usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
2647
3411
  summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
2648
3412
  when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
2649
3413
  network: true,
2650
3414
  needsKey: true,
2651
- writes: ["speclayer.json", "speclayer.local.json", ".gitignore (one line, when inside a git repo)", "<outDir>/"],
3415
+ writes: [
3416
+ "speclayer.json",
3417
+ "speclayer.local.json",
3418
+ ".gitignore (one line, when inside a git repo)",
3419
+ "<outDir>/",
3420
+ "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3421
+ "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3422
+ ],
2652
3423
  exits: OK_OR_ERROR
2653
3424
  },
2654
3425
  {
2655
3426
  name: "init",
2656
- usage: "spec-layer init --id lib_... [--out DIR] [--only foundation|components] [--component NAME]...",
2657
- summary: "Writes speclayer.json so later commands need no flags. Stores no key and reaches no server.",
3427
+ usage: "spec-layer init --id lib_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3428
+ summary: "Writes speclayer.json, with the platforms and default outputs, so later commands need no flags. Stores no key and reaches no server.",
2658
3429
  when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
2659
3430
  network: false,
2660
3431
  needsKey: false,
@@ -2663,12 +3434,16 @@ var TOOLS = [
2663
3434
  },
2664
3435
  {
2665
3436
  name: "pull",
2666
- usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--only foundation|components] [--component NAME]...",
2667
- summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
2668
- when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block.",
3437
+ usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3438
+ summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path.",
3439
+ when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks.",
2669
3440
  network: true,
2670
3441
  needsKey: true,
2671
- writes: ["<outDir>/"],
3442
+ writes: [
3443
+ "<outDir>/",
3444
+ "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3445
+ "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3446
+ ],
2672
3447
  exits: OK_OR_ERROR
2673
3448
  },
2674
3449
  {
@@ -2713,7 +3488,7 @@ var TOOLS = [
2713
3488
  },
2714
3489
  {
2715
3490
  name: "skill",
2716
- usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter] [--json] [--out DIR]",
3491
+ usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter]... [--json] [--out DIR]",
2717
3492
  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.",
2718
3493
  when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
2719
3494
  network: false,
@@ -2768,42 +3543,42 @@ function countNumberTokens(tree) {
2768
3543
  return n;
2769
3544
  }
2770
3545
  function readJson(path) {
2771
- if (!existsSync6(path)) return null;
3546
+ if (!existsSync7(path)) return null;
2772
3547
  try {
2773
- return JSON.parse(readFileSync6(path, "utf8"));
3548
+ return JSON.parse(readFileSync7(path, "utf8"));
2774
3549
  } catch {
2775
3550
  return null;
2776
3551
  }
2777
3552
  }
2778
3553
  function summarizePull(cwd, outDir, manifest) {
2779
3554
  if (!manifest) return null;
2780
- const absOut = join6(cwd, outDir);
2781
- const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.aiPath ? `${outDir}/${a.aiPath}` : null }));
3555
+ const absOut = join7(cwd, outDir);
3556
+ const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path }));
2782
3557
  const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
2783
3558
  let foundation = null;
2784
3559
  if (foundationEntry) {
2785
- const tokensDir = join6(absOut, "tokens");
2786
- const resolver = readJson(join6(tokensDir, "resolver.json"));
2787
- const report = readJson(join6(tokensDir, "report.json"));
3560
+ const tokensDir = join7(absOut, "tokens");
3561
+ const resolver = readJson(join7(tokensDir, "resolver.json"));
3562
+ const report2 = readJson(join7(tokensDir, "report.json"));
2788
3563
  let tokenFiles = [];
2789
3564
  try {
2790
- tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
3565
+ tokenFiles = readdirSync4(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
2791
3566
  } catch {
2792
3567
  tokenFiles = [];
2793
3568
  }
2794
3569
  let unitlessNumbers = 0;
2795
3570
  for (const file of tokenFiles) {
2796
3571
  if (file.startsWith("styles.")) continue;
2797
- unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
3572
+ unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
2798
3573
  }
2799
3574
  const reportCounts = {};
2800
- if (Array.isArray(report)) {
2801
- for (const entry2 of report) {
3575
+ if (Array.isArray(report2)) {
3576
+ for (const entry2 of report2) {
2802
3577
  if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
2803
3578
  }
2804
3579
  }
2805
3580
  foundation = {
2806
- written: foundationEntry.aiPath !== null && resolver !== null,
3581
+ written: foundationEntry.path !== null && resolver !== null,
2807
3582
  sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
2808
3583
  modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
2809
3584
  name,
@@ -2820,8 +3595,33 @@ function summarizePull(cwd, outDir, manifest) {
2820
3595
  libraryId: manifest.libraryId,
2821
3596
  publishedAt: manifest.publishedAt,
2822
3597
  pluginVersion: manifest.pluginVersion,
3598
+ componentSpecsDir: manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
2823
3599
  components,
2824
- foundation
3600
+ foundation,
3601
+ outputs: (manifest.outputs ?? []).map((o) => {
3602
+ const mapPath = join7(absOut, "outputs", `${o.platform}-${o.format}.map.json`);
3603
+ const map = readJson(mapPath);
3604
+ const imports = map ? readIndexImports(cwd, o) : null;
3605
+ const files = imports !== null ? [...imports, CSS_INDEX_FILE] : [];
3606
+ return {
3607
+ platform: o.platform,
3608
+ format: o.format,
3609
+ path: o.path,
3610
+ case: o.case,
3611
+ modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
3612
+ modes: o.modes ?? {},
3613
+ // index.css must be readable, not just the map, or a deleted index.css
3614
+ // (with the map still on disk from an interrupted pull) would report
3615
+ // written with an empty file list, a sentence that claims files exist.
3616
+ written: map !== null && imports !== null,
3617
+ // Distinguishes "the map is on disk but index.css is gone" from "the
3618
+ // map itself never existed" (the Foundation was excluded), so the
3619
+ // guide can name the actual cause instead of always blaming the
3620
+ // Foundation.
3621
+ indexMissing: map !== null && imports === null,
3622
+ files
3623
+ };
3624
+ })
2825
3625
  };
2826
3626
  }
2827
3627
  var code = (s) => `\`${s}\``;
@@ -2848,7 +3648,7 @@ function stackSection(input) {
2848
3648
  ""
2849
3649
  );
2850
3650
  } else {
2851
- const label = platformSource === "flag" ? "chosen with --platform" : "detected";
3651
+ const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
2852
3652
  lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
2853
3653
  }
2854
3654
  for (const platform of platforms) {
@@ -2856,10 +3656,47 @@ function stackSection(input) {
2856
3656
  const tokensDir = `${input.outDir}/tokens/`;
2857
3657
  if (platform === "web") {
2858
3658
  lines.push("### Web", "");
2859
- lines.push(
2860
- `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code("code_syntax.WEB")}, when the designer declared one in Figma. Use that identifier as the CSS custom property or theme key. When a token has no WEB entry, derive nothing: use the DTCG path as it appears in the token file (for example ${code("{Collection.group.name}")}) and say in your change that the code name is not declared in Figma.`
2861
- );
2862
- lines.push("");
3659
+ const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
3660
+ if (cssOut) {
3661
+ const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3662
+ lines.push(
3663
+ `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). Each entry names the file that declares the property. Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
3664
+ ""
3665
+ );
3666
+ const partFiles = cssOut.files.filter((f) => f !== CSS_INDEX_FILE);
3667
+ lines.push(
3668
+ `Import ${code(`${cssOut.path}/index.css`)} from the root stylesheet. It imports one file per collection and mode: ${partFiles.join(", ")}. Sets and default modes are at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)} in its own file, so a mode can also be imported alone. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). To let the OS choose, set that collection's selector to ${code(":root")} under ${code("outputs[].modes")} and import the mode's file yourself under ${code("@media (prefers-color-scheme: dark)")}; the CLI never assumes that.`,
3669
+ ""
3670
+ );
3671
+ if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
3672
+ lines.push(
3673
+ `${code(`${cssOut.path}/`)} is a projection of the same ${code(tokensDir)} files, not a second source. Import one or the other.`,
3674
+ ""
3675
+ );
3676
+ }
3677
+ } else {
3678
+ lines.push(
3679
+ `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.`,
3680
+ ""
3681
+ );
3682
+ const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
3683
+ if (configuredNotWritten?.indexMissing) {
3684
+ lines.push(
3685
+ `A web/css output is configured at ${code(`${configuredNotWritten.path}/`)} but its ${code("index.css")} is missing, so the file list is unknown. Run ${code("npx spec-layer pull")} to write it again.`,
3686
+ ""
3687
+ );
3688
+ } else if (configuredNotWritten) {
3689
+ lines.push(
3690
+ `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.`,
3691
+ ""
3692
+ );
3693
+ } else if (pull?.foundation?.written) {
3694
+ lines.push(
3695
+ `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("tokens/")}.`,
3696
+ ""
3697
+ );
3698
+ }
3699
+ }
2863
3700
  if (profile.tokenTools.includes("tailwind")) {
2864
3701
  lines.push(
2865
3702
  `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.`,
@@ -2937,7 +3774,10 @@ function pullSection(input) {
2937
3774
  } else {
2938
3775
  lines.push("- This library has no Foundation, so there is no tokens/ directory.");
2939
3776
  }
2940
- lines.push(`- ${code(`${outDir}/ai/components/`)}: one YAML per component.`);
3777
+ lines.push(`- ${code(`${pull.componentSpecsDir}/`)}: one YAML per component.`);
3778
+ for (const o of pull.outputs) {
3779
+ lines.push(o.written ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, ${o.case} names: ${o.files.join(", ")}. Non-default modes are under ${code(o.modeSelector)}, each in its own file. 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`)}.` : o.indexMissing ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, but ${code("index.css")} is missing; run ${code("npx spec-layer pull")} to restore the directory.` : `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
3780
+ }
2941
3781
  lines.push("");
2942
3782
  if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
2943
3783
  lines.push("### Token collections", "");
@@ -2991,18 +3831,21 @@ function buildSkillGuide(input) {
2991
3831
  `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.`,
2992
3832
  ""
2993
3833
  );
3834
+ const componentSpecsDir = input.pull?.componentSpecsDir ?? input.config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
2994
3835
  lines.push("## How to use it", "");
2995
3836
  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.`);
2996
- lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/ai/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.`);
3837
+ lines.push(`2. Building or changing a component: read its YAML under ${code(`${componentSpecsDir}/`)}, 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.`);
2997
3838
  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.`);
2998
3839
  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.`);
2999
3840
  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.`);
3000
- lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory. Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3841
+ const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
3842
+ const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(`${o.path}/`)).join(", ")} either: pull replaces or removes files there.` : "";
3843
+ lines.push(`6. Never edit files under ${code(outDir + "/")} or ${code(componentSpecsDir + "/")}: the next pull replaces or removes them.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3001
3844
  lines.push("");
3002
3845
  lines.push(...pullSection(input));
3003
3846
  lines.push(...stackSection(input));
3004
3847
  lines.push(...commandsSection());
3005
- 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.`);
3848
+ lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components, after changing ${code("outputs")} or ${code("componentSpecsDir")}, or when the codebase changes stack; the file is replaced, not appended.`);
3006
3849
  return `${lines.join("\n")}
3007
3850
  `;
3008
3851
  }
@@ -3072,25 +3915,25 @@ ${BLOCK_END}
3072
3915
  const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
3073
3916
  return `${existing.slice(0, begin)}${block}${after}`;
3074
3917
  }
3075
- const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3076
- return `${existing}${sep}${block}`;
3918
+ const sep2 = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3919
+ return `${existing}${sep2}${block}`;
3077
3920
  }
3078
3921
  function installSkill(cwd, host, guide) {
3079
3922
  const target = installTarget(host);
3080
- const abs = join6(cwd, target.path);
3081
- const existing = existsSync6(abs) ? readFileSync6(abs, "utf8") : null;
3923
+ const abs = join7(cwd, target.path);
3924
+ const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
3082
3925
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3083
3926
  if (existing === next) return { path: target.path, result: "unchanged" };
3084
- mkdirSync2(dirname3(abs), { recursive: true });
3085
- writeFileSync5(abs, next);
3927
+ mkdirSync3(dirname3(abs), { recursive: true });
3928
+ writeFileSync6(abs, next);
3086
3929
  return { path: target.path, result: existing === null ? "created" : "updated" };
3087
3930
  }
3088
3931
 
3089
3932
  // src/version.ts
3090
- import { readFileSync as readFileSync7 } from "node:fs";
3933
+ import { readFileSync as readFileSync8 } from "node:fs";
3091
3934
  function cliVersion() {
3092
3935
  try {
3093
- const parsed = JSON.parse(readFileSync7(new URL("../package.json", import.meta.url), "utf8"));
3936
+ const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
3094
3937
  return typeof parsed.version === "string" ? parsed.version : "unknown";
3095
3938
  } catch {
3096
3939
  return "unknown";
@@ -3108,8 +3951,8 @@ function manifestReader() {
3108
3951
  }
3109
3952
  function sameOutput(a, b) {
3110
3953
  const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
3111
- const dtcgKey = (d) => JSON.stringify(sortKeys(d ?? {}));
3112
- return selectionKey(a.selection) === selectionKey(b.selection) && dtcgKey(a.dtcg) === dtcgKey(b.dtcg);
3954
+ const key = (v) => JSON.stringify(sortKeys(v ?? {}));
3955
+ return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []) && (a.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR) === (b.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR);
3113
3956
  }
3114
3957
  function sortKeys(value) {
3115
3958
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -3118,6 +3961,35 @@ function sortKeys(value) {
3118
3961
  }
3119
3962
  return value;
3120
3963
  }
3964
+ function platformsFromFlags(flags, io2) {
3965
+ if (flags.platform === void 0 || flags.platform.length === 0) return void 0;
3966
+ const out = [];
3967
+ for (const value of flags.platform) {
3968
+ if (!isPlatform(value)) {
3969
+ io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${value}".`);
3970
+ return null;
3971
+ }
3972
+ if (!out.includes(value)) out.push(value);
3973
+ }
3974
+ return out;
3975
+ }
3976
+ function resolvePlatforms(cwd, fromFlags, config) {
3977
+ if (fromFlags) return { platforms: fromFlags, source: "flag" };
3978
+ if (config?.platforms && config.platforms.length > 0) return { platforms: config.platforms, source: "config" };
3979
+ const detected = detectRepo(cwd).platforms;
3980
+ return { platforms: detected, source: detected.length > 0 ? "detected" : "none" };
3981
+ }
3982
+ function outputsForRun(fromFlags, config, platforms) {
3983
+ if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
3984
+ return config?.outputs ?? defaultOutputs(platforms);
3985
+ }
3986
+ var NO_PLATFORM_NOTE = `No target platform detected, so no token files were written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
3987
+ function platformsMissingFormat(platforms) {
3988
+ return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
3989
+ }
3990
+ function missingFormatNote(platforms) {
3991
+ return `No token files exist yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3992
+ }
3121
3993
  var errorText = (err) => err instanceof Error ? err.message : String(err);
3122
3994
  function runInit(cwd, flags, io2) {
3123
3995
  if (!flags.id) {
@@ -3131,9 +4003,25 @@ function runInit(cwd, flags, io2) {
3131
4003
  io2.err(errorText(err));
3132
4004
  return 1;
3133
4005
  }
4006
+ const fromFlags = platformsFromFlags(flags, io2);
4007
+ if (fromFlags === null) return 1;
4008
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, null);
4009
+ const outputs = defaultOutputs(platforms);
3134
4010
  const outDir = flags.out ?? DEFAULT_OUT_DIR;
3135
- writeConfig(cwd, { libraryId: flags.id, outDir, ...include ? { include } : {} });
3136
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
4011
+ writeConfig(cwd, {
4012
+ libraryId: flags.id,
4013
+ outDir,
4014
+ componentSpecsDir: DEFAULT_COMPONENT_SPECS_DIR,
4015
+ ...include ? { include } : {},
4016
+ ...platforms.length > 0 ? { platforms } : {},
4017
+ ...outputs.length > 0 ? { outputs } : {}
4018
+ });
4019
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
4020
+ for (const o of outputs) io2.out(`Token files for ${o.platform}: ${o.path}/ (${o.format}, ${o.case} names), written by the next pull.`);
4021
+ if (source === "flag" || source === "detected") {
4022
+ const missing = platformsMissingFormat(platforms);
4023
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
4024
+ }
3137
4025
  io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
3138
4026
  return 0;
3139
4027
  }
@@ -3157,7 +4045,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
3157
4045
  }
3158
4046
  function resolvedOutDir(cwd, flags, io2) {
3159
4047
  try {
3160
- return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
4048
+ return join8(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
3161
4049
  } catch (err) {
3162
4050
  io2.err(errorText(err));
3163
4051
  return null;
@@ -3193,16 +4081,24 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3193
4081
  } catch {
3194
4082
  existing = null;
3195
4083
  }
4084
+ const fromFlags = platformsFromFlags(flags, io2);
4085
+ if (fromFlags === null) return 1;
3196
4086
  const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
4087
+ const componentSpecsDir = existing?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3197
4088
  const keptInclude = include ?? existing?.include ?? null;
3198
4089
  const keptDtcg = existing?.dtcg ?? null;
4090
+ const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
4091
+ const outputs = withDefaults(existing?.outputs ?? [], platforms);
3199
4092
  writeConfig(cwd, {
3200
4093
  libraryId: flags.id,
3201
4094
  outDir,
4095
+ componentSpecsDir,
3202
4096
  ...keptInclude ? { include: keptInclude } : {},
3203
- ...keptDtcg ? { dtcg: keptDtcg } : {}
4097
+ ...keptDtcg ? { dtcg: keptDtcg } : {},
4098
+ ...platforms.length > 0 ? { platforms } : {},
4099
+ ...existing?.outputs !== void 0 || outputs.length > 0 ? { outputs } : {}
3204
4100
  });
3205
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
4101
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
3206
4102
  const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
3207
4103
  switch (ignored.kind) {
3208
4104
  case "refused":
@@ -3249,6 +4145,12 @@ git rm --cached ${ignored.line}`);
3249
4145
  io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
3250
4146
  return 0;
3251
4147
  }
4148
+ function outputFilesOnDisk(cwd, outDir, o) {
4149
+ const mapPath = join8(cwd, outDir, "outputs", `${outputId(o)}.map.json`);
4150
+ if (!existsSync8(mapPath)) return false;
4151
+ const imports = readIndexImports(cwd, o);
4152
+ return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
4153
+ }
3252
4154
  async function runPull(cwd, flags, env, io2, fetcher) {
3253
4155
  const manifestAt = manifestReader();
3254
4156
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -3260,11 +4162,18 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3260
4162
  io2.err(errorText(err));
3261
4163
  return 1;
3262
4164
  }
3263
- const manifest = manifestAt(join7(cwd, opts.outDir));
4165
+ const fromFlags = platformsFromFlags(flags, io2);
4166
+ if (fromFlags === null) return 1;
4167
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
4168
+ const outputs = outputsForRun(fromFlags, opts, platforms);
4169
+ const manifest = manifestAt(join8(cwd, opts.outDir));
4170
+ const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4171
+ const willWriteFoundation = selection.foundation && foundationOnDisk;
4172
+ const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
3264
4173
  const etag = manifest && sameOutput(
3265
- { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
3266
- { selection, dtcg: opts.dtcg }
3267
- ) ? manifest.bundleHash : void 0;
4174
+ { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
4175
+ { selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
4176
+ ) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
3268
4177
  const result = await fetchBundle({
3269
4178
  api: opts.api,
3270
4179
  libraryId: opts.libraryId,
@@ -3281,11 +4190,13 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3281
4190
  return 0;
3282
4191
  }
3283
4192
  let written;
4193
+ let componentSpecs;
4194
+ let outputResults;
3284
4195
  try {
3285
4196
  const bundle = parseBundle(result.raw);
3286
4197
  const selected = selectComponents(bundle, selection);
3287
- written = writeBundleFiles({
3288
- outDir: join7(cwd, opts.outDir),
4198
+ const writeResult = writeBundleFiles({
4199
+ outDir: join8(cwd, opts.outDir),
3289
4200
  cwd,
3290
4201
  raw: result.raw,
3291
4202
  bundle,
@@ -3293,8 +4204,14 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3293
4204
  libraryId: opts.libraryId,
3294
4205
  publishedAt: result.publishedAt,
3295
4206
  bundleHash: result.bundleHash,
3296
- dtcg: opts.dtcg
4207
+ dtcg: opts.dtcg,
4208
+ platforms,
4209
+ outputs,
4210
+ componentSpecsDir: opts.componentSpecsDir
3297
4211
  });
4212
+ written = writeResult.written;
4213
+ componentSpecs = writeResult.componentSpecs;
4214
+ outputResults = writeResult.outputs;
3298
4215
  io2.out(
3299
4216
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
3300
4217
  );
@@ -3302,14 +4219,35 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3302
4219
  io2.err(errorText(err));
3303
4220
  return 1;
3304
4221
  }
4222
+ const count = (n) => `${n} file${n === 1 ? "" : "s"}`;
3305
4223
  io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
4224
+ if (componentSpecs.files.length > 0) io2.out(`Wrote ${componentSpecs.path}/ (${count(componentSpecs.files.length)}).`);
4225
+ for (const r of outputResults) {
4226
+ const o = outputs.find((x) => x.path === r.path);
4227
+ if (o) io2.out(`Wrote ${r.path}/ (${count(r.files.length)}, ${o.platform}/${o.format}, ${o.case} names).`);
4228
+ }
4229
+ const staleDirNote = (previous, current) => {
4230
+ if (previous !== current && existsSync8(resolve5(cwd, previous))) {
4231
+ io2.out(`The previous pull wrote ${previous}/; this one wrote ${current}/. Delete ${previous}/ if nothing else uses it.`);
4232
+ }
4233
+ };
4234
+ if (manifest) staleDirNote(manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR, opts.componentSpecsDir);
4235
+ for (const prev of manifest?.outputs ?? []) {
4236
+ const current = outputs.find((o) => outputId(o) === outputId(prev));
4237
+ if (current) staleDirNote(prev.path, current.path);
4238
+ }
4239
+ if (outputResults.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4240
+ if (source === "flag" || source === "config") {
4241
+ const missing = platformsMissingFormat(platforms);
4242
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
4243
+ }
3306
4244
  return 0;
3307
4245
  }
3308
4246
  async function runStatus(cwd, flags, env, io2, fetcher) {
3309
4247
  const manifestAt = manifestReader();
3310
4248
  const opts = resolved(cwd, flags, env, io2, manifestAt);
3311
4249
  if (!opts) return 1;
3312
- const manifest = manifestAt(join7(cwd, opts.outDir));
4250
+ const manifest = manifestAt(join8(cwd, opts.outDir));
3313
4251
  if (!manifest) {
3314
4252
  io2.err(NO_LOCAL_PULL);
3315
4253
  return 2;
@@ -3341,11 +4279,15 @@ function runList(cwd, flags, io2) {
3341
4279
  return 1;
3342
4280
  }
3343
4281
  io2.out(`Library ${manifest.libraryId}, published ${manifest.publishedAt}.`);
3344
- const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.aiPath ?? "not written", a.contentHash]);
4282
+ const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.path ?? "not written", a.contentHash]);
3345
4283
  const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
3346
4284
  for (const row of rows) {
3347
4285
  io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
3348
4286
  }
4287
+ for (const o of manifest.outputs ?? []) {
4288
+ const written = existsSync8(join8(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4289
+ io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
4290
+ }
3349
4291
  return 0;
3350
4292
  }
3351
4293
  var SHOW_USAGE = 'spec-layer show takes "foundation" or "component NAME".';
@@ -3410,20 +4352,10 @@ function collectSkillInput(cwd, flags, io2) {
3410
4352
  }
3411
4353
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
3412
4354
  const profile = detectRepo(cwd);
3413
- let platforms;
3414
- let platformSource;
3415
- if (flags.platform !== void 0) {
3416
- if (!isPlatform(flags.platform)) {
3417
- io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${flags.platform}".`);
3418
- return null;
3419
- }
3420
- platforms = [flags.platform];
3421
- platformSource = "flag";
3422
- } else {
3423
- platforms = profile.platforms;
3424
- platformSource = platforms.length > 0 ? "detected" : "none";
3425
- }
3426
- const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
4355
+ const fromFlags = platformsFromFlags(flags, io2);
4356
+ if (fromFlags === null) return null;
4357
+ const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
4358
+ const pull = summarizePull(cwd, outDir, readManifest(join8(cwd, outDir)));
3427
4359
  return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
3428
4360
  }
3429
4361
  function skillHosts(flags, input, io2) {
@@ -3484,17 +4416,18 @@ function runSkill(cwd, flags, io2) {
3484
4416
  var USAGE = `spec-layer <command>
3485
4417
 
3486
4418
  Commands:
3487
- setup --id lib_... --key sl_... [--out DIR] [selection]
4419
+ setup --id lib_... --key sl_... [--out DIR] [selection] [--platform P]...
3488
4420
  store the key, then pull
3489
- init --id lib_... [--out DIR] [selection] write speclayer.json
3490
- pull [--id lib_...] [--key sl_...] [selection]
4421
+ init --id lib_... [--out DIR] [selection] [--platform P]...
4422
+ write speclayer.json
4423
+ pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
3491
4424
  fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
3492
4425
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
3493
4426
  list list every artifact in the last pull
3494
4427
  show foundation | component NAME [--canonical]
3495
4428
  print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
3496
4429
  tools [--json] list every command with what it reaches and writes
3497
- skill [--install] [--agent HOST]... [--platform P] [--json]
4430
+ skill [--install] [--agent HOST]... [--platform P]... [--json]
3498
4431
  print a guide for a coding agent, adapted to this repo and the last pull;
3499
4432
  --install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
3500
4433
 
@@ -3504,6 +4437,7 @@ Selection (setup, pull and init; flags replace the include block in speclayer.js
3504
4437
 
3505
4438
  Options:
3506
4439
  --api URL override the API origin (default https://api.spec-layer.com)
4440
+ --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
3507
4441
  The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
3508
4442
  var io = {
3509
4443
  out: (l) => console.log(l),
@@ -3529,7 +4463,7 @@ async function main() {
3529
4463
  json: { type: "boolean" },
3530
4464
  install: { type: "boolean" },
3531
4465
  agent: { type: "string", multiple: true },
3532
- platform: { type: "string" }
4466
+ platform: { type: "string", multiple: true }
3533
4467
  }
3534
4468
  }));
3535
4469
  } catch {