spec-layer 0.6.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 +80 -42
  2. package/dist/cli.js +321 -140
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -60,7 +60,9 @@ against them. The Foundation landing under `tokens/` as Design Tokens Format
60
60
  Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
61
61
  `tools` and `skill` need 0.5.0 or later. `components/` in place of
62
62
  `ai/components/`, `path` in place of `aiPath` in the manifest, and the
63
- `outputs` block need 0.6.0 or later.
63
+ `outputs` block need 0.6.0 or later. `component-specs/` beside `tokens/`,
64
+ `componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
65
+ need 0.7.0 or later.
64
66
 
65
67
  ## Commands
66
68
 
@@ -146,7 +148,7 @@ carry no key and are meant to be committed with the rest of the repository.
146
148
  ## Pulling part of a library
147
149
 
148
150
  By default `pull` writes the Foundation and every documented component. When
149
- your repo implements only some of them, narrow what lands in `components/`:
151
+ your repo implements only some of them, narrow what lands in `component-specs/`:
150
152
 
151
153
  ```bash
152
154
  npx spec-layer pull --only foundation # tokens and styles, no components
@@ -177,7 +179,7 @@ That stores an `include` block in `speclayer.json`:
177
179
  Selection flags on `pull` replace the stored selection for that run; they are
178
180
  never merged with it.
179
181
 
180
- The selection only decides which `components/` files are written. `bundle.json`
182
+ The selection only decides which `component-specs/` files are written. `bundle.json`
181
183
  always holds the whole library, so `list` and `show` can answer for any
182
184
  artifact, written or not, and `status` compares one hash.
183
185
 
@@ -244,7 +246,7 @@ matter what the ignore rules say.
244
246
  ```text
245
247
  .speclayer/
246
248
  bundle.json the published bundle, verbatim
247
- manifest.json every artifact indexed by content hash and path, plus the selection and outputs
249
+ manifest.json every artifact indexed by content hash and path, plus the selection, outputs, and componentSpecsDir
248
250
  tokens/ the Foundation as Design Tokens Format Module 2025.10 files
249
251
  <collection>.<mode>.json one file per collection and mode, rooted at the collection name
250
252
  styles.typography.json text styles as typography composites (when present)
@@ -252,19 +254,22 @@ matter what the ignore rules say.
252
254
  resolver.json Design Tokens Resolver Module 2025.10: sets, modifiers, order
253
255
  spec-layer.meta.json Figma ids, scopes, code syntax, publication, keyed by DTCG path
254
256
  report.json what DTCG could not express, with reasons and stable ids
255
- components/<name>.yaml one file per selected component
256
257
  outputs/
257
- web-css.map.json DTCG path -> CSS custom property, with where the name came from
258
- web-css.report.json what the CSS file could not express
259
- spec-layer/
260
- tokens.css the web token file, written in place at outputs[].path
258
+ web-css.map.json DTCG path -> CSS custom property, where the name came from, and which file declares it
259
+ web-css.report.json what the CSS files could not express
260
+ component-specs/
261
+ <name>.yaml one brief per selected component, byte-identical to Copy for AI
262
+ tokens/
263
+ index.css imports every file below, in resolver order
264
+ <collection>.css one file per single-mode collection, at :root
265
+ <collection>.<mode>.css one file per mode of a multi-mode collection; the default at :root
261
266
  ```
262
267
 
263
- Point your agent at `.speclayer/components/` and `.speclayer/tokens/`, and
264
- import the token file for your platform from where `outputs` puts it. The
265
- component YAML is the same compact form the plugin's **Copy for AI** puts on
266
- your clipboard; `bundle.json` additionally holds the full canonical artifacts
267
- if you need them.
268
+ Point your agent at `component-specs/` and `.speclayer/tokens/`, and import
269
+ `tokens/index.css` from the platform's root stylesheet. The component YAML is
270
+ the same compact form the plugin's **Copy for AI** puts on your clipboard;
271
+ `bundle.json` additionally holds the full canonical artifacts if you need
272
+ them.
268
273
 
269
274
  In `manifest.json`, an artifact the selection left unwritten has `"path":
270
275
  null`. A manifest from CLI 0.1.0 has no `selection` field and means
@@ -275,12 +280,19 @@ interrupted pull never leaves a half-written directory. `pull` refuses an
275
280
  output directory that is the current directory, a parent of it, or an existing
276
281
  non-empty directory it did not write, since the swap replaces that directory.
277
282
 
283
+ `component-specs/` and `tokens/` are written in place, not swapped. `pull`
284
+ owns exactly the files there that begin with its marker (the CSS header, or
285
+ the brief's opening `spec_layer:` lines): it replaces or removes those,
286
+ ignores dotfiles, and refuses to run when anything else is present. A
287
+ repository that already uses a path can set `componentSpecsDir` or
288
+ `outputs[].path`.
289
+
278
290
  When nothing changed since the last pull with the same selection, `pull`
279
291
  prints `Already up to date` and writes nothing. Every republish stamps a new
280
292
  export id and time into the canonical artifacts, so `bundle.json` and
281
293
  `manifest.json` change on each republish even when the content did not. The
282
- `components/` YAML files, the `tokens/` files, and the content hashes stay
283
- stable.
294
+ `component-specs/` YAML files, the `.speclayer/tokens/` files, and the
295
+ `tokens/` CSS stay stable.
284
296
 
285
297
  ## Configuring the token output
286
298
 
@@ -309,19 +321,26 @@ the report are not token files; exclude them from token globs.
309
321
 
310
322
  ## Token files for your code
311
323
 
312
- `pull` also writes a file your build compiles, one per platform output. For
313
- the web that is a CSS file of custom properties. The file is written only
314
- when that pull writes the Foundation; with `--only components` it is left
315
- exactly as it was, and `list` shows it as `not written`.
324
+ `pull` also writes a `tokens/` directory at `outputs[].path`: one CSS file
325
+ per collection and mode plus `index.css`, one per platform output. The
326
+ directory is written only when that pull writes the Foundation; with
327
+ `--only components` it is left exactly as it was, and `list` shows it as
328
+ `not written`.
316
329
 
317
330
  ```css
331
+ /* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
332
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
333
+
318
334
  :root {
319
335
  /* Foundation */
320
336
  --foundation-colors-blue-500: #2e72d1;
321
337
  --foundation-spacing-200: 8px;
322
- /* Mapped Colors, Light */
323
- --mapped-colors-surface-primary-default: var(--foundation-colors-blue-500);
324
338
  }
339
+ ```
340
+
341
+ ```css
342
+ /* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
343
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
325
344
 
326
345
  [data-theme="dark"] {
327
346
  /* Mapped Colors, Dark */
@@ -329,23 +348,42 @@ exactly as it was, and `list` shows it as `not written`.
329
348
  }
330
349
  ```
331
350
 
332
- Sets and every collection's default mode sit at `:root`; every other mode is
333
- a block under `[data-theme="<mode>"]`. Aliases stay as `var()`. A number whose
334
- Figma scopes state no unit stays a bare number. Nothing about a mode's name
335
- selects a media query; wire `data-theme` to `prefers-color-scheme` yourself if
336
- the OS should choose.
351
+ `index.css` holds the header, then one comment and one import per file, in
352
+ resolver order, and no declarations of its own:
353
+
354
+ ```css
355
+ /* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
356
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
357
+
358
+ /* Foundation */
359
+ @import "./foundation.css";
360
+ /* Mapped Colors, Dark */
361
+ @import "./mapped-colors.dark.css";
362
+ ```
337
363
 
338
- The file is written **in place** at the path in `speclayer.json`, outside the
339
- managed directory, so your bundler keeps watching it and the diff shows up in
340
- review. It begins with a header naming the library and the Foundation's
341
- content hash, and `pull` refuses to overwrite a file at that path that lacks
342
- the header.
364
+ A set, which has one mode by construction, writes one file named for the
365
+ collection alone, at `:root`. A collection with modes writes one file per
366
+ mode: the default mode's file sits at `:root`, every other mode's file under
367
+ `[data-theme="<mode>"]`. Aliases stay as `var()`. A number whose Figma scopes
368
+ state no unit stays a bare number. Nothing about a mode's name selects a
369
+ media query. To let the OS choose a theme, set that collection's selector to
370
+ `:root` under `modes` and import the mode's file yourself under `@media
371
+ (prefers-color-scheme: dark)`. The CLI never assumes that.
372
+
373
+ The directory is written **in place** at the path in `speclayer.json`,
374
+ outside the managed directory, so your bundler keeps watching it and the diff
375
+ shows up in review. Every file begins with a header naming the library and
376
+ the Foundation's content hash; `pull` owns exactly the files there that begin
377
+ with it, replacing or removing those, ignoring dotfiles, and refusing to run
378
+ when anything else is present. A `speclayer.json` written by CLI 0.6.0 names
379
+ a `.css` file at this path, and `pull` refuses it: set `path` to a directory
380
+ and delete the old file.
343
381
 
344
382
  ```json
345
383
  {
346
384
  "platforms": ["web"],
347
385
  "outputs": [
348
- { "platform": "web", "format": "css", "path": "spec-layer/tokens.css", "case": "kebab" }
386
+ { "platform": "web", "format": "css", "path": "tokens", "case": "kebab" }
349
387
  ]
350
388
  }
351
389
  ```
@@ -355,28 +393,28 @@ at the repository root, so the path is always on record. `pull` writes every
355
393
  entry; `"outputs": []` writes none. `case` chooses how derived names are
356
394
  spelled: `kebab` (default), `camel`, `pascal`, `snake`, or `constant`. A name
357
395
  the designer declared as `code_syntax` in Figma is used verbatim and never
358
- re-cased. `.speclayer/outputs/web-css.map.json` records every emitted name and
359
- whether it was declared or derived; two tokens that would share a name are
360
- both omitted and listed in `web-css.report.json`.
396
+ re-cased. `.speclayer/outputs/web-css.map.json` records every emitted name,
397
+ whether it was declared or derived, and which file declares it; two tokens
398
+ that would share a name are both omitted and listed in `web-css.report.json`.
361
399
 
362
400
  Two collections with modes share one attribute by default, which cannot be
363
401
  right for both; the report says so, and `modes` declares a selector per
364
402
  collection:
365
403
 
366
404
  ```json
367
- { "platform": "web", "format": "css", "path": "spec-layer/tokens.css",
405
+ { "platform": "web", "format": "css", "path": "tokens",
368
406
  "modes": { "Density": "[data-density=\"{mode}\"]" } }
369
407
  ```
370
408
 
371
409
  `root` and `modeSelector` override the defaults `:root` and
372
410
  `[data-theme="{mode}"]`; `{mode}` and `{collection}` are replaced by slugs.
373
411
  A repository that already builds tokens with Style Dictionary can keep reading
374
- `tokens/`: the CSS file is a projection of the same files, not a second
375
- source, so import one or the other.
412
+ `.speclayer/tokens/`: the CSS files are a projection of the same record, not a
413
+ second source, so import one or the other.
376
414
 
377
- Commit `.speclayer/`, `speclayer.json`, and the output paths. A repository
378
- that would rather regenerate in CI ignores them and runs `pull` there;
379
- `status` exits `2` when a pull is due.
415
+ Commit `.speclayer/`, `speclayer.json`, `component-specs/`, and the output
416
+ paths. A repository that would rather regenerate in CI ignores them and runs
417
+ `pull` there; `status` exits `2` when a pull is due.
380
418
 
381
419
  ## Exit codes
382
420
 
package/dist/cli.js CHANGED
@@ -560,7 +560,7 @@ import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
562
  import { existsSync as existsSync8 } from "node:fs";
563
- import { join as join7, resolve as resolve4 } from "node:path";
563
+ import { join as join8, resolve as resolve5 } from "node:path";
564
564
 
565
565
  // ../extractor/src/statesMatrix.ts
566
566
  var STATE_ORDER = [
@@ -1334,7 +1334,15 @@ function dtcgSegments(name) {
1334
1334
  }
1335
1335
  return { segments, notes };
1336
1336
  }
1337
- 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;
1338
1346
  var RESERVED_FILE_NAMES = [
1339
1347
  "styles.typography.json",
1340
1348
  "styles.effects.json",
@@ -2121,6 +2129,7 @@ function resolveNames(paths, meta, rules) {
2121
2129
  }
2122
2130
 
2123
2131
  // ../extractor/src/v5/outputs/css.ts
2132
+ var CSS_INDEX_FILE = "index.css";
2124
2133
  var CSS_DEFAULTS = {
2125
2134
  case: "kebab",
2126
2135
  root: ":root",
@@ -2186,6 +2195,23 @@ function sourcesOf(resolver) {
2186
2195
  }
2187
2196
  var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
2188
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
+ }
2189
2215
  var REF = /^\{(.+)\}$/;
2190
2216
  function hexChannels(hex) {
2191
2217
  const at = (i) => parseInt(hex.slice(i, i + 2), 16);
@@ -2394,17 +2420,22 @@ function headerText(header, nameCase) {
2394
2420
  function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2395
2421
  const entries = [];
2396
2422
  const declared = /* @__PURE__ */ new Set();
2423
+ const firstFile = /* @__PURE__ */ new Map();
2397
2424
  const blocks = /* @__PURE__ */ new Map();
2398
2425
  for (const s of sources) {
2399
2426
  const perCollection = modes?.[s.collection];
2400
2427
  const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2401
2428
  const decls = [];
2429
+ const declaredHere = [];
2402
2430
  for (const leaf of leavesByFile.get(s.file) ?? []) {
2403
2431
  const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2404
2432
  if (leaf.type === "typography") {
2405
2433
  const t = typographyDecls(ctx, leaf, names);
2406
2434
  decls.push(...t.decls);
2407
- for (const p of t.declaredPaths) declared.add(p);
2435
+ for (const p of t.declaredPaths) {
2436
+ declared.add(p);
2437
+ declaredHere.push(p);
2438
+ }
2408
2439
  } else {
2409
2440
  const name = names.get(leaf.path);
2410
2441
  if (name === void 0) continue;
@@ -2413,22 +2444,26 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2413
2444
  if (d !== null) {
2414
2445
  decls.push(d);
2415
2446
  declared.add(leaf.path);
2447
+ declaredHere.push(leaf.path);
2416
2448
  }
2417
2449
  } else {
2418
2450
  const v = cssValue(ctx, leaf.type, leaf.value);
2419
2451
  if (v !== null) {
2420
2452
  decls.push(`${name}: ${v};`);
2421
2453
  declared.add(leaf.path);
2454
+ declaredHere.push(leaf.path);
2422
2455
  }
2423
2456
  }
2424
2457
  }
2425
2458
  }
2426
2459
  if (decls.length === 0) continue;
2427
- const lines = blocks.get(selector) ?? [];
2428
- lines.push(` /* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`, ...decls.map((d) => ` ${d}`));
2429
- blocks.set(selector, lines);
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 });
2430
2465
  }
2431
- return { blocks, entries, declared };
2466
+ return { blocks, entries, declared, firstFile };
2432
2467
  }
2433
2468
  function cssOutput(exp, header, options = {}) {
2434
2469
  const nameCase = options.case ?? CSS_DEFAULTS.case;
@@ -2458,7 +2493,7 @@ function cssOutput(exp, header, options = {}) {
2458
2493
  const resolved2 = resolveNames([...candidatePaths], exp.meta, {
2459
2494
  codeSyntaxKey: "WEB",
2460
2495
  acceptDeclared: acceptCssDeclared,
2461
- affix: (body2) => `--${body2}`,
2496
+ affix: (body) => `--${body}`,
2462
2497
  nameCase
2463
2498
  });
2464
2499
  const names = resolved2.names;
@@ -2470,9 +2505,11 @@ function cssOutput(exp, header, options = {}) {
2470
2505
  alive = pass.declared;
2471
2506
  pass = emit(alive);
2472
2507
  }
2508
+ const fileNames = cssFileNames(sources);
2473
2509
  const map = {};
2474
2510
  for (const [path, entry2] of Object.entries(resolved2.map)) {
2475
- if (alive.has(path)) map[path] = entry2;
2511
+ const from = pass.firstFile.get(path);
2512
+ if (alive.has(path) && from !== void 0) map[path] = { ...entry2, file: fileNames.get(from) };
2476
2513
  }
2477
2514
  const entries = [...resolved2.report, ...pass.entries];
2478
2515
  const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
@@ -2487,10 +2524,25 @@ function cssOutput(exp, header, options = {}) {
2487
2524
  });
2488
2525
  }
2489
2526
  }
2490
- const body = [...pass.blocks].flatMap(([selector, lines]) => [`${selector} {`, ...lines, "}", ""]);
2491
- const text = `${[headerText(header, nameCase), "", ...body].join("\n").trimEnd()}
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")}
2492
2544
  `;
2493
- return { text, map, report: sortReport(entries) };
2545
+ return { files, map, report: sortReport(entries) };
2494
2546
  }
2495
2547
 
2496
2548
  // ../extractor/src/v5/componentContext.ts
@@ -2585,7 +2637,7 @@ function parseBundle(raw) {
2585
2637
 
2586
2638
  // src/config.ts
2587
2639
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
2588
- import { join as join3 } from "node:path";
2640
+ import { join as join4 } from "node:path";
2589
2641
 
2590
2642
  // src/credentials.ts
2591
2643
  import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
@@ -2768,17 +2820,93 @@ function isAgentHost(value) {
2768
2820
  }
2769
2821
 
2770
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
2771
2827
  import {
2828
+ closeSync,
2772
2829
  existsSync as existsSync3,
2830
+ lstatSync,
2773
2831
  mkdirSync,
2774
- readFileSync as readFileSync3,
2832
+ openSync,
2833
+ readSync,
2834
+ readdirSync as readdirSync2,
2775
2835
  renameSync,
2776
2836
  rmSync,
2777
2837
  writeFileSync as writeFileSync2
2778
2838
  } from "node:fs";
2779
- import { dirname, isAbsolute, relative, resolve } from "node:path";
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
2780
2908
  var FORMATS = [
2781
- { platform: "web", format: "css", defaultPath: "spec-layer/tokens.css", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2909
+ { platform: "web", format: "css", defaultPath: "tokens", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2782
2910
  ];
2783
2911
  var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
2784
2912
  var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
@@ -2844,45 +2972,27 @@ function renderOutput(exp, o, header) {
2844
2972
  }
2845
2973
  }
2846
2974
  }
2847
- var inside = (parent, child) => {
2848
- const rel = relative(parent, child);
2849
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2850
- };
2851
- function outputPathProblem(cwd, outDir, o) {
2852
- const root = resolve(cwd);
2853
- const abs = resolve(cwd, o.path);
2854
- if (!inside(root, abs) || abs === root) return `${o.path} is outside this directory. Choose a path inside the repository.`;
2855
- if (inside(resolve(cwd, outDir), abs)) {
2856
- return `${o.path} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
2857
- }
2858
- if (existsSync3(abs)) {
2859
- const prefix = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
2860
- let head;
2861
- try {
2862
- head = readFileSync3(abs, "utf8").slice(0, prefix.length);
2863
- } catch {
2864
- return `${o.path} exists and could not be read.`;
2865
- }
2866
- if (head !== prefix) return `${o.path} exists and was not written by spec-layer. Choose another path or remove the file.`;
2867
- }
2868
- return null;
2869
- }
2870
- function writeOutputFile(cwd, o, text) {
2871
- const abs = resolve(cwd, o.path);
2872
- mkdirSync(dirname(abs), { recursive: true });
2873
- const partial = `${abs}.partial`;
2874
- writeFileSync2(partial, text);
2975
+ function readIndexImports(cwd, o) {
2976
+ const path = resolve2(cwd, o.path, CSS_INDEX_FILE);
2977
+ let text;
2875
2978
  try {
2876
- renameSync(partial, abs);
2877
- } catch (err) {
2878
- rmSync(partial, { force: true });
2879
- throw err;
2979
+ text = readFileSync3(path, "utf8");
2980
+ } catch {
2981
+ return null;
2880
2982
  }
2983
+ return [...text.matchAll(/^@import "\.\/([^"\n]+)";$/gm)].map((m) => m[1]);
2984
+ }
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");
2881
2990
  }
2882
2991
 
2883
2992
  // src/config.ts
2884
2993
  var DEFAULT_API = "https://api.spec-layer.com";
2885
2994
  var DEFAULT_OUT_DIR = ".speclayer";
2995
+ var DEFAULT_COMPONENT_SPECS_DIR = "component-specs";
2886
2996
  var CONFIG_NAME = "speclayer.json";
2887
2997
  var invalidConfig = () => new Error(`${CONFIG_NAME} is not valid JSON. Fix or delete it, then retry.`);
2888
2998
  function parseInclude(value) {
@@ -2914,6 +3024,14 @@ function parseDtcg(value) {
2914
3024
  }
2915
3025
  return out;
2916
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
+ }
2917
3035
  function parsePlatforms(value) {
2918
3036
  if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
2919
3037
  throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
@@ -2934,7 +3052,7 @@ function parseOutputs(value) {
2934
3052
  return outputs;
2935
3053
  }
2936
3054
  function readConfig(cwd) {
2937
- const path = join3(cwd, CONFIG_NAME);
3055
+ const path = join4(cwd, CONFIG_NAME);
2938
3056
  if (!existsSync4(path)) return null;
2939
3057
  let parsed;
2940
3058
  try {
@@ -2947,6 +3065,7 @@ function readConfig(cwd) {
2947
3065
  return {
2948
3066
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
2949
3067
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
3068
+ ...record.componentSpecsDir !== void 0 ? { componentSpecsDir: parseComponentSpecsDir(record.componentSpecsDir) } : {},
2950
3069
  ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2951
3070
  ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
2952
3071
  ...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
@@ -2957,18 +3076,19 @@ function writeConfig(cwd, config) {
2957
3076
  const body = {
2958
3077
  libraryId: config.libraryId,
2959
3078
  outDir: config.outDir,
3079
+ ...config.componentSpecsDir ? { componentSpecsDir: config.componentSpecsDir } : {},
2960
3080
  ...config.include ? { include: config.include } : {},
2961
3081
  ...config.dtcg ? { dtcg: config.dtcg } : {},
2962
3082
  ...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
2963
3083
  ...config.outputs ? { outputs: config.outputs } : {}
2964
3084
  };
2965
- writeFileSync3(join3(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
3085
+ writeFileSync3(join4(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2966
3086
  `);
2967
3087
  }
2968
3088
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
2969
3089
  const config = readConfig(cwd);
2970
3090
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
2971
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join3(cwd, outDir));
3091
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join4(cwd, outDir));
2972
3092
  const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2973
3093
  let storedKey = null;
2974
3094
  let storedKeyFor;
@@ -2982,6 +3102,7 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
2982
3102
  return {
2983
3103
  libraryId,
2984
3104
  outDir,
3105
+ componentSpecsDir: config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
2985
3106
  // A trailing slash would build "//v1/..." paths the proxy router 404s on.
2986
3107
  api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
2987
3108
  key: supplied ?? storedKey,
@@ -3027,8 +3148,8 @@ async function fetchBundle(opts) {
3027
3148
  }
3028
3149
 
3029
3150
  // src/files.ts
3030
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync2, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3031
- import { join as join4, dirname as dirname2, relative as relative2, resolve as resolve2, isAbsolute as isAbsolute2 } from "node:path";
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";
3032
3153
 
3033
3154
  // src/selection.ts
3034
3155
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -3066,8 +3187,9 @@ function slugify(name) {
3066
3187
  const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3067
3188
  return slug2 || "component";
3068
3189
  }
3190
+ var COMPONENT_SPEC_MARKER = "spec_layer:\n kind: component";
3069
3191
  function readManifest(outDir) {
3070
- const path = join4(outDir, "manifest.json");
3192
+ const path = join5(outDir, "manifest.json");
3071
3193
  if (!existsSync5(path)) return null;
3072
3194
  try {
3073
3195
  const parsed = JSON.parse(readFileSync5(path, "utf8"));
@@ -3084,7 +3206,7 @@ function readManifest(outDir) {
3084
3206
  }
3085
3207
  }
3086
3208
  function readLocalBundle(outDir) {
3087
- const path = join4(outDir, "bundle.json");
3209
+ const path = join5(outDir, "bundle.json");
3088
3210
  if (!existsSync5(path)) return null;
3089
3211
  try {
3090
3212
  return parseBundle(readFileSync5(path, "utf8"));
@@ -3114,11 +3236,11 @@ function componentSlugs(bundle) {
3114
3236
  });
3115
3237
  }
3116
3238
  function assertReplaceable(outDir, cwd) {
3117
- const rel = relative2(resolve2(cwd), resolve2(outDir));
3239
+ const rel = relative2(resolve3(cwd), resolve3(outDir));
3118
3240
  if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
3119
3241
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
3120
3242
  }
3121
- if (existsSync5(outDir) && !existsSync5(join4(outDir, "manifest.json")) && readdirSync2(outDir).length > 0) {
3243
+ if (existsSync5(outDir) && !existsSync5(join5(outDir, "manifest.json")) && readdirSync3(outDir).length > 0) {
3122
3244
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
3123
3245
  }
3124
3246
  }
@@ -3128,13 +3250,23 @@ function writeBundleFiles(opts) {
3128
3250
  const selected = selectComponents(opts.bundle, selection);
3129
3251
  const slugs = componentSlugs(opts.bundle);
3130
3252
  const outputs = opts.outputs ?? [];
3131
- const willWriteFoundation = Boolean(opts.bundle.foundation) && selection.foundation;
3132
- if (willWriteFoundation) {
3133
- for (const o of outputs) {
3134
- const problem = outputPathProblem(opts.cwd, opts.outDir, o);
3135
- if (problem) throw new Error(problem);
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.`);
3136
3267
  }
3137
- }
3268
+ briefs[`${slugs[i]}.yaml`] = component.ai;
3269
+ });
3138
3270
  const staging = `${opts.outDir}.partial`;
3139
3271
  rmSync2(staging, { recursive: true, force: true });
3140
3272
  const written = [];
@@ -3142,8 +3274,8 @@ function writeBundleFiles(opts) {
3142
3274
  const json = (v) => `${JSON.stringify(v, null, 2)}
3143
3275
  `;
3144
3276
  const put = (rel, content) => {
3145
- const path = join4(staging, rel);
3146
- mkdirSync2(dirname2(path), { recursive: true });
3277
+ const path = join5(staging, rel);
3278
+ mkdirSync2(dirname(path), { recursive: true });
3147
3279
  writeFileSync4(path, content);
3148
3280
  written.push(rel);
3149
3281
  };
@@ -3159,13 +3291,13 @@ function writeBundleFiles(opts) {
3159
3291
  }
3160
3292
  const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3161
3293
  for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3162
- path = "tokens/resolver.json";
3294
+ path = `${outDirRel}/tokens/resolver.json`;
3163
3295
  const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
3164
3296
  for (const output of outputs) {
3165
3297
  const rendered = renderOutput(exp, output, header);
3166
3298
  put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
3167
3299
  put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
3168
- deliverables.push({ output, text: rendered.text });
3300
+ deliverables.push({ output, files: rendered.files });
3169
3301
  }
3170
3302
  }
3171
3303
  artifacts.push({
@@ -3176,13 +3308,11 @@ function writeBundleFiles(opts) {
3176
3308
  });
3177
3309
  }
3178
3310
  opts.bundle.components.forEach((component, i) => {
3179
- const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
3180
- if (path) put(path, component.ai);
3181
3311
  artifacts.push({
3182
3312
  kind: "component",
3183
3313
  name: component.name,
3184
3314
  contentHash: component.artifact.spec_layer.export.content_hash,
3185
- path
3315
+ path: selected[i] ? `${componentSpecsDir}/${slugs[i]}.yaml` : null
3186
3316
  });
3187
3317
  });
3188
3318
  const manifest = {
@@ -3192,6 +3322,7 @@ function writeBundleFiles(opts) {
3192
3322
  pluginVersion: opts.bundle.pluginVersion,
3193
3323
  extractorVersion: opts.bundle.extractorVersion,
3194
3324
  selection,
3325
+ componentSpecsDir,
3195
3326
  artifacts,
3196
3327
  ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
3197
3328
  ...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
@@ -3204,18 +3335,18 @@ function writeBundleFiles(opts) {
3204
3335
  }
3205
3336
  rmSync2(opts.outDir, { recursive: true, force: true });
3206
3337
  renameSync2(staging, opts.outDir);
3207
- const outputPaths = [];
3338
+ const componentSpecs = { path: componentSpecsDir, files: writeVisibleDir(opts.cwd, componentSpecsDir, COMPONENT_SPEC_MARKER, briefs) };
3339
+ const outputResults = [];
3208
3340
  for (const d of deliverables) {
3209
- writeOutputFile(opts.cwd, d.output, d.text);
3210
- outputPaths.push(d.output.path);
3341
+ outputResults.push({ path: d.output.path, files: writeVisibleDir(opts.cwd, d.output.path, CSS_HEADER_PREFIX, d.files, CSS_INDEX_FILE) });
3211
3342
  }
3212
- return { written, outputs: outputPaths };
3343
+ return { written, componentSpecs, outputs: outputResults };
3213
3344
  }
3214
3345
 
3215
3346
  // src/gitignore.ts
3216
3347
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3217
3348
  import { spawnSync } from "node:child_process";
3218
- import { join as join5, dirname as dirname3, resolve as resolve3 } from "node:path";
3349
+ import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
3219
3350
  var COMMENT = "# Spec Layer pull key, not for committing";
3220
3351
  function git(cwd, args) {
3221
3352
  const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
@@ -3223,10 +3354,10 @@ function git(cwd, args) {
3223
3354
  return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
3224
3355
  }
3225
3356
  function insideWorkTreeWithoutGit(cwd) {
3226
- let dir = resolve3(cwd);
3357
+ let dir = resolve4(cwd);
3227
3358
  for (; ; ) {
3228
- if (existsSync6(join5(dir, ".git"))) return true;
3229
- const parent = dirname3(dir);
3359
+ if (existsSync6(join6(dir, ".git"))) return true;
3360
+ const parent = dirname2(dir);
3230
3361
  if (parent === dir) return false;
3231
3362
  dir = parent;
3232
3363
  }
@@ -3242,7 +3373,7 @@ function ensureIgnored(cwd, fileName) {
3242
3373
  if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
3243
3374
  const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
3244
3375
  if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
3245
- const path = join5(cwd, ".gitignore");
3376
+ const path = join6(cwd, ".gitignore");
3246
3377
  const existed = existsSync6(path);
3247
3378
  try {
3248
3379
  if (!existed) {
@@ -3267,8 +3398,8 @@ ${fileName}
3267
3398
  }
3268
3399
 
3269
3400
  // src/skill.ts
3270
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3271
- import { dirname as dirname4, join as join6 } from "node:path";
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";
3272
3403
 
3273
3404
  // src/tools.ts
3274
3405
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
@@ -3286,7 +3417,8 @@ var TOOLS = [
3286
3417
  "speclayer.local.json",
3287
3418
  ".gitignore (one line, when inside a git repo)",
3288
3419
  "<outDir>/",
3289
- "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"
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"
3290
3422
  ],
3291
3423
  exits: OK_OR_ERROR
3292
3424
  },
@@ -3303,11 +3435,15 @@ var TOOLS = [
3303
3435
  {
3304
3436
  name: "pull",
3305
3437
  usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3306
- summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
3307
- when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block, or the outputs block.",
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.",
3308
3440
  network: true,
3309
3441
  needsKey: true,
3310
- writes: ["<outDir>/", "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"],
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
+ ],
3311
3447
  exits: OK_OR_ERROR
3312
3448
  },
3313
3449
  {
@@ -3416,24 +3552,24 @@ function readJson(path) {
3416
3552
  }
3417
3553
  function summarizePull(cwd, outDir, manifest) {
3418
3554
  if (!manifest) return null;
3419
- const absOut = join6(cwd, outDir);
3420
- const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path ? `${outDir}/${a.path}` : null }));
3555
+ const absOut = join7(cwd, outDir);
3556
+ const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path }));
3421
3557
  const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
3422
3558
  let foundation = null;
3423
3559
  if (foundationEntry) {
3424
- const tokensDir = join6(absOut, "tokens");
3425
- const resolver = readJson(join6(tokensDir, "resolver.json"));
3426
- const report2 = 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"));
3427
3563
  let tokenFiles = [];
3428
3564
  try {
3429
- 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();
3430
3566
  } catch {
3431
3567
  tokenFiles = [];
3432
3568
  }
3433
3569
  let unitlessNumbers = 0;
3434
3570
  for (const file of tokenFiles) {
3435
3571
  if (file.startsWith("styles.")) continue;
3436
- unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
3572
+ unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
3437
3573
  }
3438
3574
  const reportCounts = {};
3439
3575
  if (Array.isArray(report2)) {
@@ -3459,20 +3595,33 @@ function summarizePull(cwd, outDir, manifest) {
3459
3595
  libraryId: manifest.libraryId,
3460
3596
  publishedAt: manifest.publishedAt,
3461
3597
  pluginVersion: manifest.pluginVersion,
3598
+ componentSpecsDir: manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
3462
3599
  components,
3463
3600
  foundation,
3464
- outputs: (manifest.outputs ?? []).map((o) => ({
3465
- platform: o.platform,
3466
- format: o.format,
3467
- path: o.path,
3468
- case: o.case,
3469
- modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
3470
- modes: o.modes ?? {},
3471
- // manifest.outputs records the configured list regardless of whether the
3472
- // Foundation was written; the map file exists only when it was actually
3473
- // rendered, so it is the on-disk proof a sentence can point to.
3474
- written: existsSync7(join6(absOut, "outputs", `${o.platform}-${o.format}.map.json`))
3475
- }))
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
+ })
3476
3625
  };
3477
3626
  }
3478
3627
  var code = (s) => `\`${s}\``;
@@ -3511,16 +3660,17 @@ function stackSection(input) {
3511
3660
  if (cssOut) {
3512
3661
  const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3513
3662
  lines.push(
3514
- `The CSS custom property for every token is in ${code(mapPath)}: source "code_syntax" when the designer declared it in Figma, "derived" when the CLI built it from the DTCG path by the stated rule (${cssOut.case} case, collection root included). Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
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.`,
3515
3664
  ""
3516
3665
  );
3666
+ const partFiles = cssOut.files.filter((f) => f !== CSS_INDEX_FILE);
3517
3667
  lines.push(
3518
- `Import ${code(cssOut.path)} from the root stylesheet. It holds every set and every default mode at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)}. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). Wire it to prefers-color-scheme yourself if the OS should choose; the file never assumes that.`,
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.`,
3519
3669
  ""
3520
3670
  );
3521
3671
  if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
3522
3672
  lines.push(
3523
- `${code(cssOut.path)} is a projection of the same ${code("tokens/")} files, not a second source. Import one or the other.`,
3673
+ `${code(`${cssOut.path}/`)} is a projection of the same ${code(tokensDir)} files, not a second source. Import one or the other.`,
3524
3674
  ""
3525
3675
  );
3526
3676
  }
@@ -3530,14 +3680,19 @@ function stackSection(input) {
3530
3680
  ""
3531
3681
  );
3532
3682
  const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
3533
- if (configuredNotWritten) {
3683
+ if (configuredNotWritten?.indexMissing) {
3534
3684
  lines.push(
3535
- `A web/css output is configured at ${code(configuredNotWritten.path)} but was not written, because the last pull did not write the Foundation. Pull with the Foundation selected to write it.`,
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.`,
3536
3691
  ""
3537
3692
  );
3538
3693
  } else if (pull?.foundation?.written) {
3539
3694
  lines.push(
3540
- `No token file was written for web. Add \`"outputs"\` in \`speclayer.json\` (or run \`spec-layer pull --platform web\` once) and pull again; the default lands at ${code("spec-layer/tokens.css")}.`,
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/")}.`,
3541
3696
  ""
3542
3697
  );
3543
3698
  }
@@ -3619,9 +3774,9 @@ function pullSection(input) {
3619
3774
  } else {
3620
3775
  lines.push("- This library has no Foundation, so there is no tokens/ directory.");
3621
3776
  }
3622
- lines.push(`- ${code(`${outDir}/components/`)}: one YAML per component.`);
3777
+ lines.push(`- ${code(`${pull.componentSpecsDir}/`)}: one YAML per component.`);
3623
3778
  for (const o of pull.outputs) {
3624
- lines.push(o.written ? `- ${code(o.path)}: ${o.platform}/${o.format} token file, ${o.case} names, modes under ${code(o.modeSelector)}. Names and provenance: ${code(`${outDir}/outputs/${o.platform}-${o.format}.map.json`)}; what it could not express: ${code(`${outDir}/outputs/${o.platform}-${o.format}.report.json`)}.` : `- ${code(o.path)}: ${o.platform}/${o.format} token file, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
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.`);
3625
3780
  }
3626
3781
  lines.push("");
3627
3782
  if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
@@ -3676,20 +3831,21 @@ function buildSkillGuide(input) {
3676
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.`,
3677
3832
  ""
3678
3833
  );
3834
+ const componentSpecsDir = input.pull?.componentSpecsDir ?? input.config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3679
3835
  lines.push("## How to use it", "");
3680
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.`);
3681
- lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
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.`);
3682
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.`);
3683
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.`);
3684
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.`);
3685
3841
  const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
3686
- const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(o.path)).join(", ")} either: pull replaces ${writtenOutputs.length === 1 ? "it" : "them"} in place.` : "";
3687
- lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
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.`);
3688
3844
  lines.push("");
3689
3845
  lines.push(...pullSection(input));
3690
3846
  lines.push(...stackSection(input));
3691
3847
  lines.push(...commandsSection());
3692
- lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
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.`);
3693
3849
  return `${lines.join("\n")}
3694
3850
  `;
3695
3851
  }
@@ -3759,16 +3915,16 @@ ${BLOCK_END}
3759
3915
  const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
3760
3916
  return `${existing.slice(0, begin)}${block}${after}`;
3761
3917
  }
3762
- const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3763
- 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}`;
3764
3920
  }
3765
3921
  function installSkill(cwd, host, guide) {
3766
3922
  const target = installTarget(host);
3767
- const abs = join6(cwd, target.path);
3923
+ const abs = join7(cwd, target.path);
3768
3924
  const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
3769
3925
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3770
3926
  if (existing === next) return { path: target.path, result: "unchanged" };
3771
- mkdirSync3(dirname4(abs), { recursive: true });
3927
+ mkdirSync3(dirname3(abs), { recursive: true });
3772
3928
  writeFileSync6(abs, next);
3773
3929
  return { path: target.path, result: existing === null ? "created" : "updated" };
3774
3930
  }
@@ -3796,7 +3952,7 @@ function manifestReader() {
3796
3952
  function sameOutput(a, b) {
3797
3953
  const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
3798
3954
  const key = (v) => JSON.stringify(sortKeys(v ?? {}));
3799
- return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []);
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);
3800
3956
  }
3801
3957
  function sortKeys(value) {
3802
3958
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -3827,12 +3983,12 @@ function outputsForRun(fromFlags, config, platforms) {
3827
3983
  if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
3828
3984
  return config?.outputs ?? defaultOutputs(platforms);
3829
3985
  }
3830
- var NO_PLATFORM_NOTE = `No target platform detected, so no token file was written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
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.`;
3831
3987
  function platformsMissingFormat(platforms) {
3832
3988
  return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
3833
3989
  }
3834
3990
  function missingFormatNote(platforms) {
3835
- return `No token file exists yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3991
+ return `No token files exist yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3836
3992
  }
3837
3993
  var errorText = (err) => err instanceof Error ? err.message : String(err);
3838
3994
  function runInit(cwd, flags, io2) {
@@ -3855,12 +4011,13 @@ function runInit(cwd, flags, io2) {
3855
4011
  writeConfig(cwd, {
3856
4012
  libraryId: flags.id,
3857
4013
  outDir,
4014
+ componentSpecsDir: DEFAULT_COMPONENT_SPECS_DIR,
3858
4015
  ...include ? { include } : {},
3859
4016
  ...platforms.length > 0 ? { platforms } : {},
3860
4017
  ...outputs.length > 0 ? { outputs } : {}
3861
4018
  });
3862
4019
  io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
3863
- for (const o of outputs) io2.out(`Token file for ${o.platform}: ${o.path} (${o.format}, ${o.case} names), written by the next pull.`);
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.`);
3864
4021
  if (source === "flag" || source === "detected") {
3865
4022
  const missing = platformsMissingFormat(platforms);
3866
4023
  if (missing.length > 0) io2.out(missingFormatNote(missing));
@@ -3888,7 +4045,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
3888
4045
  }
3889
4046
  function resolvedOutDir(cwd, flags, io2) {
3890
4047
  try {
3891
- return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
4048
+ return join8(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
3892
4049
  } catch (err) {
3893
4050
  io2.err(errorText(err));
3894
4051
  return null;
@@ -3927,6 +4084,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3927
4084
  const fromFlags = platformsFromFlags(flags, io2);
3928
4085
  if (fromFlags === null) return 1;
3929
4086
  const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
4087
+ const componentSpecsDir = existing?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3930
4088
  const keptInclude = include ?? existing?.include ?? null;
3931
4089
  const keptDtcg = existing?.dtcg ?? null;
3932
4090
  const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
@@ -3934,6 +4092,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3934
4092
  writeConfig(cwd, {
3935
4093
  libraryId: flags.id,
3936
4094
  outDir,
4095
+ componentSpecsDir,
3937
4096
  ...keptInclude ? { include: keptInclude } : {},
3938
4097
  ...keptDtcg ? { dtcg: keptDtcg } : {},
3939
4098
  ...platforms.length > 0 ? { platforms } : {},
@@ -3986,6 +4145,12 @@ git rm --cached ${ignored.line}`);
3986
4145
  io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
3987
4146
  return 0;
3988
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
+ }
3989
4154
  async function runPull(cwd, flags, env, io2, fetcher) {
3990
4155
  const manifestAt = manifestReader();
3991
4156
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -4001,13 +4166,14 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4001
4166
  if (fromFlags === null) return 1;
4002
4167
  const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
4003
4168
  const outputs = outputsForRun(fromFlags, opts, platforms);
4004
- const manifest = manifestAt(join7(cwd, opts.outDir));
4169
+ const manifest = manifestAt(join8(cwd, opts.outDir));
4005
4170
  const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4006
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)));
4007
4173
  const etag = manifest && sameOutput(
4008
- { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs },
4009
- { selection, dtcg: opts.dtcg, outputs }
4010
- ) && (!willWriteFoundation || outputs.every((o) => existsSync8(resolve4(cwd, o.path)) && existsSync8(join7(cwd, opts.outDir, "outputs", `${outputId(o)}.map.json`)))) ? manifest.bundleHash : void 0;
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;
4011
4177
  const result = await fetchBundle({
4012
4178
  api: opts.api,
4013
4179
  libraryId: opts.libraryId,
@@ -4024,12 +4190,13 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4024
4190
  return 0;
4025
4191
  }
4026
4192
  let written;
4027
- let outputPaths = [];
4193
+ let componentSpecs;
4194
+ let outputResults;
4028
4195
  try {
4029
4196
  const bundle = parseBundle(result.raw);
4030
4197
  const selected = selectComponents(bundle, selection);
4031
4198
  const writeResult = writeBundleFiles({
4032
- outDir: join7(cwd, opts.outDir),
4199
+ outDir: join8(cwd, opts.outDir),
4033
4200
  cwd,
4034
4201
  raw: result.raw,
4035
4202
  bundle,
@@ -4039,10 +4206,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4039
4206
  bundleHash: result.bundleHash,
4040
4207
  dtcg: opts.dtcg,
4041
4208
  platforms,
4042
- outputs
4209
+ outputs,
4210
+ componentSpecsDir: opts.componentSpecsDir
4043
4211
  });
4044
4212
  written = writeResult.written;
4045
- outputPaths = writeResult.outputs;
4213
+ componentSpecs = writeResult.componentSpecs;
4214
+ outputResults = writeResult.outputs;
4046
4215
  io2.out(
4047
4216
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
4048
4217
  );
@@ -4050,12 +4219,24 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4050
4219
  io2.err(errorText(err));
4051
4220
  return 1;
4052
4221
  }
4222
+ const count = (n) => `${n} file${n === 1 ? "" : "s"}`;
4053
4223
  io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
4054
- for (const path of outputPaths) {
4055
- const o = outputs.find((x) => x.path === path);
4056
- if (o) io2.out(`Wrote ${path} (${o.platform}/${o.format}, ${o.case} names).`);
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);
4057
4238
  }
4058
- if (outputPaths.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4239
+ if (outputResults.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4059
4240
  if (source === "flag" || source === "config") {
4060
4241
  const missing = platformsMissingFormat(platforms);
4061
4242
  if (missing.length > 0) io2.out(missingFormatNote(missing));
@@ -4066,7 +4247,7 @@ async function runStatus(cwd, flags, env, io2, fetcher) {
4066
4247
  const manifestAt = manifestReader();
4067
4248
  const opts = resolved(cwd, flags, env, io2, manifestAt);
4068
4249
  if (!opts) return 1;
4069
- const manifest = manifestAt(join7(cwd, opts.outDir));
4250
+ const manifest = manifestAt(join8(cwd, opts.outDir));
4070
4251
  if (!manifest) {
4071
4252
  io2.err(NO_LOCAL_PULL);
4072
4253
  return 2;
@@ -4104,7 +4285,7 @@ function runList(cwd, flags, io2) {
4104
4285
  io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
4105
4286
  }
4106
4287
  for (const o of manifest.outputs ?? []) {
4107
- const written = existsSync8(join7(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4288
+ const written = existsSync8(join8(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4108
4289
  io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
4109
4290
  }
4110
4291
  return 0;
@@ -4174,7 +4355,7 @@ function collectSkillInput(cwd, flags, io2) {
4174
4355
  const fromFlags = platformsFromFlags(flags, io2);
4175
4356
  if (fromFlags === null) return null;
4176
4357
  const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
4177
- const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
4358
+ const pull = summarizePull(cwd, outDir, readManifest(join8(cwd, outDir)));
4178
4359
  return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
4179
4360
  }
4180
4361
  function skillHosts(flags, input, io2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-layer",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pull design-system context published by the Spec Layer Figma plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",