@getvitops/cli 0.9.0 → 2.0.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.
package/dist/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
- import { dirname, extname, join, parse, relative, resolve } from "node:path";
6
- import { SCHEMA_URL, defaultConfig, expandPalette, functionalRole, generate, generateDocs, roleColorUtilities, validate } from "@getvitops/generator";
7
- import { generateFavicons } from "@getvitops/utils";
4
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, parse, relative, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isSiteConfig, resolveInput, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
8
+ import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
8
9
 
9
10
  //#region src/agents.ts
10
11
  /**
@@ -39,6 +40,10 @@ const TOPICS = {
39
40
  path: "concepts/scales.md",
40
41
  summary: "fluid modular type & space scales"
41
42
  },
43
+ icons: {
44
+ path: "concepts/icons.md",
45
+ summary: "semantic icon names across sets, bundle derivation, sprite delivery"
46
+ },
42
47
  patterns: {
43
48
  path: "concepts/patterns.md",
44
49
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
@@ -94,53 +99,314 @@ function linkSkill(linkPath, targetDir) {
94
99
  }
95
100
  }
96
101
 
102
+ //#endregion
103
+ //#region src/css-scan.ts
104
+ /** Strip comments, preserving newlines so line numbers survive. */
105
+ function stripComments(css) {
106
+ return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
107
+ }
108
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
109
+ /**
110
+ * Parse declarations from a rule body. Splits on top-level `;` only, so a
111
+ * `var(--x, a; b)` or a data URI cannot end a declaration early.
112
+ */
113
+ function parseDeclarations(body) {
114
+ const out = [];
115
+ let depth = 0;
116
+ let start = 0;
117
+ const push = (chunk) => {
118
+ const colon = chunk.indexOf(":");
119
+ if (colon < 0) return;
120
+ const property = chunk.slice(0, colon).trim();
121
+ const value = chunk.slice(colon + 1).trim();
122
+ if (!property || property.includes("{") || !value) return;
123
+ out.push({
124
+ property,
125
+ value
126
+ });
127
+ };
128
+ for (let i = 0; i < body.length; i++) {
129
+ const ch = body[i];
130
+ if (ch === "(" || ch === "[") depth++;
131
+ else if (ch === ")" || ch === "]") depth--;
132
+ else if (ch === ";" && depth === 0) {
133
+ push(body.slice(start, i));
134
+ start = i + 1;
135
+ }
136
+ }
137
+ push(body.slice(start));
138
+ return out;
139
+ }
140
+ /**
141
+ * Scan a stylesheet into its style rules.
142
+ *
143
+ * Never throws: this runs over consumer source that may be a fragment, and a
144
+ * linter that dies on one unparseable file is one nobody keeps in CI.
145
+ */
146
+ function scanCss(css) {
147
+ const src = stripComments(css);
148
+ const rules = [];
149
+ const stack = [];
150
+ let depth = 0;
151
+ let preludeStart = 0;
152
+ for (let i = 0; i < src.length; i++) {
153
+ const ch = src[i];
154
+ if (ch === "\"" || ch === "'") {
155
+ const quote = ch;
156
+ i++;
157
+ while (i < src.length && src[i] !== quote) {
158
+ if (src[i] === "\\") i++;
159
+ i++;
160
+ }
161
+ continue;
162
+ }
163
+ if (ch === "{") {
164
+ const raw = src.slice(preludeStart, i);
165
+ const prelude = raw.trim();
166
+ stack.push({
167
+ prelude,
168
+ index: preludeStart + raw.indexOf(prelude.slice(0, 1))
169
+ });
170
+ depth++;
171
+ preludeStart = i + 1;
172
+ continue;
173
+ }
174
+ if (ch === "}") {
175
+ const frame = stack.pop();
176
+ depth--;
177
+ if (frame && !frame.prelude.startsWith("@")) {
178
+ const bodyStart = src.indexOf("{", frame.index) + 1;
179
+ rules.push({
180
+ selector: frame.prelude,
181
+ declarations: parseDeclarations(src.slice(bodyStart, i)),
182
+ conditions: stack.filter((f) => f.prelude.startsWith("@")).map((f) => f.prelude),
183
+ line: lineAt(src, frame.index)
184
+ });
185
+ }
186
+ preludeStart = i + 1;
187
+ continue;
188
+ }
189
+ }
190
+ return rules.filter((r) => r.selector && depth >= 0);
191
+ }
192
+ /** `<style>` block contents from an .astro/.vue/.svelte/.html file. */
193
+ function extractStyleBlocks(src) {
194
+ const out = [];
195
+ for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
196
+ const line = lineAt(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
197
+ out.push({
198
+ css: m[1] ?? "",
199
+ line
200
+ });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ //#endregion
206
+ //#region src/lint-css.ts
207
+ /**
208
+ * The framework's own track widths. Declared in two places today
209
+ * (`core/css/layout.css` and the tailwind emitter), so a rule keyed to the token
210
+ * NAME rather than to a value survives either one moving.
211
+ */
212
+ const TRACK_WIDTHS = [
213
+ "--width-measure",
214
+ "--width-breakout",
215
+ "--width-spotlight"
216
+ ];
217
+ /**
218
+ * Column ratios that correspond to a `split-<a>-<b>` class.
219
+ *
220
+ * `.split` is FLEX, not grid — `display: flex` with `flex: 1` children, and a
221
+ * ratio class setting `--_split-a`/`--_split-b`. Equal columns are the base
222
+ * `.split` with no ratio class, which is why `1fr 1fr` is deliberately absent:
223
+ * suggesting `split-1-1` would name a class that does not exist, and this file
224
+ * would then be committing the very defect `lint.ts` reports.
225
+ */
226
+ const SPLITS = {
227
+ "1fr 2fr": "1-2",
228
+ "2fr 1fr": "2-1",
229
+ "1fr 3fr": "1-3",
230
+ "3fr 1fr": "3-1",
231
+ "1fr 4fr": "1-4",
232
+ "4fr 1fr": "4-1",
233
+ "2fr 3fr": "2-3",
234
+ "3fr 2fr": "3-2"
235
+ };
236
+ const decl = (rule, property) => rule.declarations.find((d) => d.property === property)?.value;
237
+ /** Is this rule inside a `min-width` media or container query? */
238
+ const inMinWidthQuery = (rule) => rule.conditions.some((c) => /min-width\s*:/.test(c));
239
+ /** The framework breakpoint a `min-width` condition corresponds to, if any. */
240
+ function breakpointOf(rule) {
241
+ const rem = rule.conditions.map((c) => /min-width\s*:\s*([\d.]+)rem/.exec(c)?.[1]).find(Boolean);
242
+ if (!rem) return null;
243
+ const n = Number(rem);
244
+ return n >= 80 ? "xl" : n >= 64 ? "lg" : n >= 48 ? "md" : n >= 30 ? "sm" : null;
245
+ }
246
+ /** Spell a responsive utility the way the target format actually emits it. */
247
+ const responsive = (bp, base, format) => format === "tailwind" ? `@${bp}:${base}` : `${bp}-${base}`;
248
+ /**
249
+ * One rule = one check. Kept as data rather than a chain of ifs so adding a
250
+ * primitive is adding an entry, and so each carries its own explanation.
251
+ */
252
+ const RULES = [
253
+ (rule) => {
254
+ const token = TRACK_WIDTHS.find((t) => decl(rule, "max-inline-size")?.includes(t) || decl(rule, "max-width")?.includes(t));
255
+ const centred = /\bauto\b/.test(decl(rule, "margin-inline") ?? decl(rule, "margin") ?? "");
256
+ if (!token || !centred) return null;
257
+ return {
258
+ cls: `${rule.selector} { max-inline-size: var(${token}); margin-inline: auto }`,
259
+ severity: "suggestion",
260
+ reason: "`.centered` is this pattern — a grid of named tracks, not a max-width plus auto margins",
261
+ suggestion: "use `.centered`, and widen an individual child with `breakout`, `spotlight` or `fullbleed` on that child rather than overriding the parent"
262
+ };
263
+ },
264
+ (rule, format) => {
265
+ const cols = decl(rule, "grid-template-columns");
266
+ if (!cols || !inMinWidthQuery(rule)) return null;
267
+ const ratio = SPLITS[cols.replace(/\s+/g, " ").trim()];
268
+ const bp = ratio && breakpointOf(rule);
269
+ if (!ratio || !bp) return null;
270
+ return {
271
+ cls: `${rule.selector} { grid-template-columns: ${cols} }`,
272
+ severity: "suggestion",
273
+ reason: `a ${cols} split behind a min-width query is the \`split-${ratio}\` utility`,
274
+ suggestion: `${responsive(bp, `split-${ratio}`, format)} on the container — the responsive form is equal below the breakpoint and takes the ratio above it, which is what the query is doing by hand`
275
+ };
276
+ },
277
+ (rule, format) => {
278
+ if (decl(rule, "display") !== "flex") return null;
279
+ if (decl(rule, "align-items") !== "center") return null;
280
+ if (/column/.test(decl(rule, "flex-direction") ?? "")) return null;
281
+ return {
282
+ cls: `${rule.selector} { display: flex; align-items: center }`,
283
+ severity: "suggestion",
284
+ reason: "the framework has this as utilities",
285
+ suggestion: format === "tailwind" ? "Tailwind's own `flex items-center` (the framework drops these names in this format)" : "`flex items-center`, or `.cluster-start` if this is a row of related controls"
286
+ };
287
+ }
288
+ ];
289
+ /**
290
+ * Lint stylesheets and the `<style>` blocks of component files.
291
+ *
292
+ * `files` carries raw text, same as `lintSource` — this module stays free of the
293
+ * filesystem so the caller owns discovery.
294
+ */
295
+ /**
296
+ * The banner every generated stylesheet opens with.
297
+ *
298
+ * Linting the generator's own output is the worst false positive this tool can
299
+ * produce: the emitted bundle contains the framework's real implementations of
300
+ * these very patterns, so it reports `.split` as reinventing `.split`. The
301
+ * `css` format writes `styles.css` into the consumer's source tree (`src/styles`
302
+ * by default), which is squarely inside what `--src` scans.
303
+ *
304
+ * Matched on the banner rather than on a path, because the output directory is
305
+ * configurable and a path rule would silently stop working when someone changes
306
+ * it.
307
+ */
308
+ const GENERATED = /^\s*\/\*!?\s*GENERATED\b|do not edit by hand\./;
309
+ function lintCss(files, format) {
310
+ const findings = [];
311
+ const seen = /* @__PURE__ */ new Set();
312
+ for (const file of files) {
313
+ if (GENERATED.test(file.text.slice(0, 400))) continue;
314
+ const blocks = file.path.endsWith(".css") ? [{
315
+ css: file.text,
316
+ line: 1
317
+ }] : extractStyleBlocks(file.text);
318
+ for (const block of blocks) for (const rule of scanCss(block.css)) for (const check of RULES) {
319
+ const verdict = check(rule, format);
320
+ if (!verdict) continue;
321
+ const line = block.line + rule.line - 1;
322
+ const key = `${file.path}:${line}:${verdict.reason}`;
323
+ if (seen.has(key)) continue;
324
+ seen.add(key);
325
+ findings.push({
326
+ file: file.path,
327
+ line,
328
+ ...verdict
329
+ });
330
+ break;
331
+ }
332
+ }
333
+ return findings;
334
+ }
335
+
97
336
  //#endregion
98
337
  //#region src/lint.ts
338
+ /**
339
+ * `vitops lint` — find framework classes in consumer source that resolve to
340
+ * nothing in the format being built.
341
+ *
342
+ * The problem this exists for: an unknown utility class is indistinguishable
343
+ * from a working one. Nothing errors, nothing warns, the element just doesn't
344
+ * get the style. Two shapes of that were found in a real consumer site:
345
+ *
346
+ * • `bg-navy-d` — a leftover from the named-step scale (`d`/`xd`/`xxd`)
347
+ * deleted in favour of numeric steps. The hue is real, the step is not.
348
+ * It appeared ~10× and looked fine only because the `.card` it sat on
349
+ * supplied its own background.
350
+ * • `md-flex-row` — the css/bricks spelling of a responsive variant, used in
351
+ * a `tailwind`-format project where that class is never emitted (Tailwind
352
+ * regenerates those on demand as `@md:`). Silently inert.
353
+ *
354
+ * DESIGN: this deliberately only inspects classes ANCHORED TO THE CONSUMER'S
355
+ * OWN CONFIG — a hue name, a role name, a type role, a shadow, a pattern. It
356
+ * never tries to enumerate "all valid classes", because in the tailwind format
357
+ * that would mean knowing Tailwind's entire built-in vocabulary, and every
358
+ * unknown would be a false positive. Anchoring means a finding is always
359
+ * actionable: you named something from your design system, and it doesn't
360
+ * resolve.
361
+ */
99
362
  const UTIL_FAMILIES = [
100
363
  "bg",
101
364
  "text",
365
+ "icon",
102
366
  "border",
103
367
  "outline",
104
368
  "fill",
105
369
  "stroke"
106
370
  ];
107
- const ROLE_MODIFIERS = [
108
- "",
109
- "muted",
110
- "x-muted",
111
- "bold",
112
- "x-bold",
113
- "solid",
114
- "solid-bold",
115
- "bg-bold"
116
- ];
371
+ /**
372
+ * The modifiers actually emitted for one family + role, read back out of the
373
+ * generator's own class list.
374
+ *
375
+ * This used to be a hand-maintained array, which is the wrong shape for a
376
+ * suggestion: the two role kinds emit different modifiers (a chromatic role has
377
+ * `solid` and no bare form; a surface role the reverse), so a single list was
378
+ * wrong for one of them no matter what it said.
379
+ */
380
+ const emittedModifiers = (roleClasses, fam, role) => {
381
+ const prefix = `${fam}-${role}`;
382
+ return [...roleClasses].filter((c) => c === prefix || c.startsWith(`${prefix}-`)).map((c) => c.slice(prefix.length + 1)).filter(Boolean).sort();
383
+ };
117
384
  function vocabulary(ds, roleClasses) {
118
385
  return {
119
386
  roles: Object.keys(ds.colors?.roles ?? {}),
120
387
  hues: Object.keys(ds.colors?.palette ?? {}),
121
- steps: [
122
- "50",
123
- "100",
124
- "200",
125
- "300",
126
- "400",
127
- "500",
128
- "600",
129
- "700",
130
- "800",
131
- "900",
132
- "950"
133
- ],
388
+ steps: [...NUMERIC_STEPS],
134
389
  typeRoles: Object.keys(ds.typography?.roles ?? {}),
135
390
  shadows: Object.keys(ds.shadows ?? {}),
136
391
  patterns: Object.keys(ds.patterns?.items ?? {}),
137
392
  roleClasses: new Set(roleClasses)
138
393
  };
139
394
  }
140
- /** Strip Tailwind variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate. */
141
- const stripVariants = (cls) => {
395
+ /**
396
+ * Strip variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate.
397
+ *
398
+ * Format-aware, because the container-query spelling only exists in tailwind.
399
+ * Stripping `@md:` unconditionally made the check asymmetric: `md-split-1-2` in a
400
+ * tailwind project was caught, but `@md:split-1-2` in a css/bricks project became
401
+ * a bare `split-1-2` that matched nothing and returned null — the same silent
402
+ * no-op, in the other direction, unreported.
403
+ */
404
+ const stripVariants = (cls, format) => {
142
405
  let out = cls;
143
- while (/^@?[a-zA-Z0-9_-]+:/.test(out)) out = out.slice(out.indexOf(":") + 1);
406
+ while (/^@?[a-zA-Z0-9_-]+:/.test(out)) {
407
+ if (format !== "tailwind" && out.startsWith("@")) break;
408
+ out = out.slice(out.indexOf(":") + 1);
409
+ }
144
410
  return out;
145
411
  };
146
412
  const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.startsWith(p));
@@ -149,10 +415,18 @@ const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.star
149
415
  * which is the common case and the whole reason this is low-noise.
150
416
  */
151
417
  function judge(raw, v, format) {
152
- const cls = stripVariants(raw);
418
+ const cls = stripVariants(raw, format);
419
+ const cq = /^@(sm|md|lg|xl):(.+)$/.exec(cls);
420
+ if (cq && format !== "tailwind") return {
421
+ cls: raw,
422
+ severity: "error",
423
+ reason: `\`@${cq[1]}:\` container-query variants are a tailwind-format spelling; the ${format} format emits pre-expanded classes`,
424
+ suggestion: `${cq[1]}-${cq[2]}`
425
+ };
153
426
  const bp = /^(sm|md|lg|xl)-(.+)$/.exec(cls);
154
427
  if (bp && format === "tailwind") return {
155
428
  cls: raw,
429
+ severity: "error",
156
430
  reason: `\`${bp[1]}-\` responsive classes are not emitted in the tailwind format`,
157
431
  suggestion: `@${bp[1]}:${bp[2]} (framework breakpoints) or ${bp[1]}:${bp[2]} (Tailwind's)`
158
432
  };
@@ -166,21 +440,25 @@ function judge(raw, v, format) {
166
440
  const role = prefix(v.roles);
167
441
  if (role && (!hue || role.length > hue.length)) {
168
442
  const mod = rest.slice(role.length + 1);
443
+ const valid = emittedModifiers(v.roleClasses, fam, role);
169
444
  return {
170
445
  cls: raw,
446
+ severity: "error",
171
447
  reason: `\`${fam}-${role}${mod ? `-${mod}` : ""}\` is not emitted for the \`${role}\` role`,
172
- suggestion: `valid modifiers: ${ROLE_MODIFIERS.filter(Boolean).join(", ")}`
448
+ suggestion: valid.length ? `valid modifiers: ${valid.join(", ")}` : `the \`${role}\` role emits no \`${fam}-*\` utilities`
173
449
  };
174
450
  }
175
451
  if (hue) {
176
452
  const step = rest.slice(hue.length + 1);
177
453
  if (step === "") return {
178
454
  cls: raw,
455
+ severity: "error",
179
456
  reason: `\`${fam}-${hue}\` names a palette hue with no step`,
180
457
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"}`
181
458
  };
182
459
  if (!v.steps.includes(step)) return {
183
460
  cls: raw,
461
+ severity: "error",
184
462
  reason: `\`${step}\` is not a step on the \`${hue}\` scale (steps are 50…950)`,
185
463
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"} which also flips in dark mode`
186
464
  };
@@ -193,6 +471,7 @@ function judge(raw, v, format) {
193
471
  const near = nearest(font[1], v.typeRoles);
194
472
  if (near) return {
195
473
  cls: raw,
474
+ severity: "error",
196
475
  reason: `\`${font[1]}\` is not a typography role`,
197
476
  suggestion: `font-${near}`
198
477
  };
@@ -200,6 +479,7 @@ function judge(raw, v, format) {
200
479
  const ds = /^drop-shadow-(.+)$/.exec(base);
201
480
  if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
202
481
  cls: raw,
482
+ severity: "error",
203
483
  reason: `\`${ds[1]}\` is not a defined shadow`,
204
484
  suggestion: `drop-shadow-${v.shadows[0]}`
205
485
  };
@@ -242,37 +522,71 @@ function lintSource(files, v, format) {
242
522
  /**
243
523
  * `vitops` — CLI for the Vitops design-system generator.
244
524
  *
245
- * vitops generate --input <json> --format <bricks|css|tailwind[,…]> --out <dir>
525
+ * vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
246
526
  * vitops init [--out design-system.json] [--force]
247
- * vitops validate <design-system.json>
248
- * vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>]
527
+ * vitops validate <design-system.json|site.json>
528
+ * vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>] [--background <hex>]
249
529
  * vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
250
530
  * vitops docs [topic] [--input <json>] [--all]
251
531
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
532
+ * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
252
533
  *
253
534
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
254
535
  * Every client brings their own consumer-editable design-system.json.
255
536
  */
256
537
  const FORMATS = new Set([
538
+ "bricks",
539
+ "css",
540
+ "tailwind",
541
+ "design"
542
+ ]);
543
+ const LINT_FORMATS = new Set([
257
544
  "bricks",
258
545
  "css",
259
546
  "tailwind"
260
547
  ]);
548
+ const LEGAL_DOCS = new Set([
549
+ "privacy",
550
+ "terms",
551
+ "cookies"
552
+ ]);
553
+ const LEGAL_OUTPUTS = new Set([
554
+ "md",
555
+ "html",
556
+ "portable-text"
557
+ ]);
261
558
  const HELP = `vitops — generate design-system outputs from a design-system.json
262
559
 
560
+ Anywhere a command takes --input, that file may be a design-system.json OR the
561
+ larger site config that embeds one (company.json / site.json). The two are told
562
+ apart by shape, and a site config also supplies the site-level facts generation
563
+ reads — its default colour scheme, its legal documents, its icon sprite.
564
+
263
565
  Usage:
264
566
  vitops generate [options] Generate platform output from a config
265
567
  vitops init [options] Scaffold a starter design-system.json
266
- vitops validate <file> Validate a config against the schema
568
+ vitops validate <file> Validate a config against the schema it says it is
267
569
  vitops favicon [options] Generate a favicon set from a source image
268
570
  vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
269
571
  vitops docs [topic] Print live design-system reference docs to stdout
270
572
  vitops lint [options] Report framework classes in your source that resolve to nothing
573
+ vitops legal [options] Render legal documents from a site config
574
+ vitops icons [options] Report which icons your source uses, and build the sprite
271
575
 
272
576
  Generate options:
273
- -i, --input <path> Config file (default: ./design-system.json)
274
- -f, --format <list> bricks | css | tailwind (comma-separated; default: bricks)
577
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
578
+ -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
579
+ design emits DESIGN.md only (the agent-facing brief) — pair it
580
+ with --out . to write it beside AGENTS.md, or compose:
581
+ --format css,design
275
582
  -o, --out <dir> Output directory (default: ./dist)
583
+ --theme <name> Which designSystem.themes entry to build, when --input is
584
+ a site config (default: its defaultTheme, else "default")
585
+ --site <path> Site config, when it is a different file from --input.
586
+ Emits legal/*.html into the output directory (see:
587
+ vitops legal). Omitted with a plain design-system.json:
588
+ no legal files.
589
+ --site-env <env> Environment whose A/B variant applies (default: production)
276
590
 
277
591
  Init options:
278
592
  -o, --out <path> Where to write (default: ./design-system.json)
@@ -282,9 +596,13 @@ Favicon options:
282
596
  -i, --input <path> Source SVG or PNG (required)
283
597
  -o, --out <dir> Output directory (default: ./public)
284
598
  --low-res <path> Optional simplified source for the 16px icon
599
+ --background <hex> Background under the maskable icons, which must be
600
+ opaque (default: #ffffff)
285
601
 
286
602
  Agents options:
287
- -i, --input <path> Config file (default: ./design-system.json; validated if present)
603
+ -i, --input <path> design-system.json or site config (default: ./design-system.json;
604
+ validated if present)
605
+ --theme <name> Theme to read, when --input is a site config
288
606
  -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
289
607
  --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
290
608
  instead of linking the packaged skill
@@ -292,14 +610,39 @@ Agents options:
292
610
  Docs options:
293
611
  <topic> classes | authoring | formats | color | scales | patterns | elements
294
612
  (no topic: list topics with summaries)
295
- -i, --input <path> Config file (default: ./design-system.json)
613
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
614
+ --theme <name> Theme to render docs from, when --input is a site config
296
615
  --all Print every topic, concatenated
297
616
 
617
+ Icons options:
618
+ --site <path> Site config carrying the "icons" block (default: ./site.json)
619
+ --src <dir> Source to scan for icon usage (default: ./src)
620
+ --sprite Also build the SVG sprite
621
+ -o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
622
+ --json Machine-readable report on stdout
623
+
298
624
  Lint options:
299
- -i, --input <path> Config file (default: ./design-system.json)
625
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
626
+ --theme <name> Theme to judge classes against, when --input is a site config
300
627
  -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
301
628
  Responsive md-* classes are real in css/bricks, inert in tailwind.
302
629
  -s, --src <dir> Directory to scan (default: ./src)
630
+ --strict Also fail on suggestions (reuse hints), not just on
631
+ classes that resolve to nothing
632
+
633
+ Legal options:
634
+ -i, --input <path> Site config: .json, or a .js/.ts module with a default
635
+ export (default: ./site.json)
636
+ -d, --doc <name> privacy | terms | cookies (repeatable; default: those
637
+ enabled in the config)
638
+ -f, --format <fmt> md | html | portable-text (default: md)
639
+ html suits WordPress/Bricks and plain sites;
640
+ portable-text suits EmDash
641
+ -o, --out <dir> Write files here (default: print to stdout)
642
+ --site-env <env> Environment whose A/B variant applies (default: production)
643
+
644
+ Generated from your config — a starting point, not legal advice. Review before
645
+ publishing, and make sure the config describes what your site actually does.
303
646
 
304
647
  Common:
305
648
  -h, --help Show this help
@@ -326,19 +669,27 @@ async function cmdGenerate(argv) {
326
669
  type: "string",
327
670
  short: "o",
328
671
  default: "dist"
329
- }
672
+ },
673
+ site: { type: "string" },
674
+ theme: { type: "string" },
675
+ "site-env": { type: "string" }
330
676
  },
331
677
  allowPositionals: false
332
678
  });
333
679
  const input = resolve(values.input);
334
680
  if (!existsSync(input)) fail(`config not found: ${input}`);
681
+ const site = values.site ? resolve(values.site) : void 0;
682
+ if (site && !existsSync(site)) fail(`site config not found: ${site}`);
335
683
  const formats = values.format.split(",").map((f) => f.trim()).filter(Boolean);
336
- for (const f of formats) if (!FORMATS.has(f)) fail(`unknown format "${f}" (expected: bricks | css | tailwind)`);
684
+ for (const f of formats) if (!FORMATS.has(f)) fail(`unknown format "${f}" (expected: bricks | css | tailwind | design)`);
337
685
  for (const format of formats) try {
338
686
  const res = await generate({
339
687
  input,
340
688
  format,
341
- outDir: values.out
689
+ outDir: values.out,
690
+ ...site ? { site } : {},
691
+ ...values.theme ? { theme: values.theme } : {},
692
+ ...values["site-env"] ? { siteEnv: values["site-env"] } : {}
342
693
  });
343
694
  console.log(`✓ ${format} → ${res.outDir} (${res.written.length} paths)`);
344
695
  } catch (err) {
@@ -368,13 +719,25 @@ function cmdInit(argv) {
368
719
  console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
369
720
  console.log(` Editors read the "$schema" (${SCHEMA_URL}) for autocomplete + validation.`);
370
721
  }
722
+ /**
723
+ * Validate a config against the schema its shape says it is.
724
+ *
725
+ * Routing on kind rather than asking the caller matters here: pointed at a site
726
+ * config, the design-system schema used to report a single `unrecognized_keys`
727
+ * for `designSystem` and nothing about the file's actual contents — a wrong
728
+ * answer that reads like a right one. A site config is validated whole, which
729
+ * also covers the cross-field integrity JSON Schema can't express.
730
+ */
371
731
  function cmdValidate(argv) {
372
732
  const { values, positionals } = parseArgs({
373
733
  args: argv,
374
- options: { input: {
375
- type: "string",
376
- short: "i"
377
- } },
734
+ options: {
735
+ input: {
736
+ type: "string",
737
+ short: "i"
738
+ },
739
+ "site-env": { type: "string" }
740
+ },
378
741
  allowPositionals: true
379
742
  });
380
743
  const path = resolve(positionals[0] ?? values.input ?? "design-system.json");
@@ -385,6 +748,30 @@ function cmdValidate(argv) {
385
748
  } catch (err) {
386
749
  fail(`could not parse JSON: ${err.message}`);
387
750
  }
751
+ if (isSiteConfig(raw)) {
752
+ let site;
753
+ try {
754
+ site = resolveSiteConfig(raw, values["site-env"]);
755
+ } catch (err) {
756
+ console.error(`✖ ${path} is invalid as a site config:`);
757
+ console.error(err.message.replace(/^Invalid site config:\n/, ""));
758
+ process.exit(1);
759
+ }
760
+ const themes = site.designSystem?.themes ?? {};
761
+ let bad = false;
762
+ for (const name of Object.keys(themes)) {
763
+ const result = validate(resolveTheme(themes, name));
764
+ for (const w of result.warnings) console.warn(` ! designSystem.themes.${name}: ${w}`);
765
+ if (result.ok) continue;
766
+ bad = true;
767
+ console.error(`✖ ${path} — designSystem.themes.${name} is not a complete design system:`);
768
+ for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
769
+ }
770
+ if (bad) process.exit(1);
771
+ const n = Object.keys(themes).length;
772
+ console.log(`✓ ${path} is a valid site config (${n} theme${n === 1 ? "" : "s"})`);
773
+ return;
774
+ }
388
775
  const result = validate(raw);
389
776
  for (const w of result.warnings) console.warn(` ! ${w}`);
390
777
  if (result.ok) {
@@ -408,7 +795,8 @@ async function cmdFavicon(argv) {
408
795
  short: "o",
409
796
  default: "public"
410
797
  },
411
- "low-res": { type: "string" }
798
+ "low-res": { type: "string" },
799
+ background: { type: "string" }
412
800
  },
413
801
  allowPositionals: false
414
802
  });
@@ -419,14 +807,24 @@ async function cmdFavicon(argv) {
419
807
  const written = await generateFavicons({
420
808
  source,
421
809
  outputDir: values.out,
422
- ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {}
810
+ ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {},
811
+ ...values.background ? { backgroundColor: values.background } : {}
423
812
  });
424
813
  console.log(`✓ favicons → ${values.out} (${written.length} files)`);
425
814
  } catch (err) {
426
815
  fail(err.message);
427
816
  }
428
817
  }
429
- function loadConfig(path) {
818
+ /**
819
+ * Read + parse + validate the design system at `path`, or exit with a message.
820
+ *
821
+ * `path` may be a `design-system.json` **or** the larger site config that embeds
822
+ * one — every command that judges source against the design system (`docs`,
823
+ * `lint`, `agents`) accepts either, so a consumer who keeps their tokens in
824
+ * `company.json` doesn't have to maintain a second file for the tooling's sake.
825
+ * `resolveInput` tells them apart by shape and resolves the theme.
826
+ */
827
+ function loadConfig(path, theme) {
430
828
  if (!existsSync(path)) fail(`config not found: ${path}`);
431
829
  let raw;
432
830
  try {
@@ -434,15 +832,233 @@ function loadConfig(path) {
434
832
  } catch (err) {
435
833
  fail(`could not parse JSON: ${err.message}`);
436
834
  }
437
- const result = validate(raw);
835
+ let resolved;
836
+ try {
837
+ resolved = resolveInput(raw, theme != null ? { theme } : {});
838
+ } catch (err) {
839
+ fail(`${path}: ${err.message}`);
840
+ }
841
+ const result = validate(resolved.designSystem);
438
842
  if (!result.ok) {
439
- console.error(`✖ ${path} is invalid:`);
843
+ const where = resolved.theme != null ? ` (designSystem.themes.${resolved.theme})` : "";
844
+ console.error(`✖ ${path}${where} is invalid:`);
440
845
  for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
441
846
  process.exit(1);
442
847
  }
443
848
  for (const w of result.warnings) console.warn(` ! ${w}`);
444
849
  return result.data;
445
850
  }
851
+ /**
852
+ * Read + resolve a site config, or exit with a message.
853
+ *
854
+ * Accepts JSON, or a JS/TS module with a default export — deliberately not YAML,
855
+ * which would mean a parser dependency for one command. `resolveSiteConfig` is
856
+ * documented as taking an already-parsed object precisely so the loader is the
857
+ * consumer's choice; anyone on YAML can convert it or export from a module.
858
+ */
859
+ async function loadSiteConfig(path, siteEnv) {
860
+ if (!existsSync(path)) fail(`site config not found: ${path}`);
861
+ let raw;
862
+ if (/\.(json)$/.test(path)) try {
863
+ raw = JSON.parse(readFileSync(path, "utf8"));
864
+ } catch (err) {
865
+ fail(`could not parse JSON: ${err.message}`);
866
+ }
867
+ else {
868
+ try {
869
+ raw = (await import(pathToFileURL(path).href)).default;
870
+ } catch (err) {
871
+ fail(`could not import ${path}: ${err.message}`);
872
+ }
873
+ if (raw == null) fail(`${path} has no default export`);
874
+ }
875
+ try {
876
+ return resolveSiteConfig(raw, siteEnv);
877
+ } catch (err) {
878
+ fail(err.message);
879
+ }
880
+ }
881
+ /**
882
+ * Render the site's legal documents.
883
+ *
884
+ * The universal delivery path: every consumer has this CLI regardless of stack,
885
+ * so a WordPress theme, an Eleventy build or a hand-written HTML site all get
886
+ * the same documents without any integration code. Prints to stdout like
887
+ * `vitops docs`, or writes files when given `--out`.
888
+ */
889
+ /**
890
+ * Report the icon vocabulary a project actually uses, and optionally build the
891
+ * sprite from it.
892
+ *
893
+ * A sibling of `legal` rather than a widening of `lint`: `lint` judges classes
894
+ * against a design-system.json, whereas icons are anchored to a SiteConfig.
895
+ *
896
+ * Exit codes carry the same distinction the Astro integration makes. A name the
897
+ * config DECLARES but that doesn't resolve is a config error and fails the
898
+ * command; a name only the SCAN found is reported and tolerated, because a bare
899
+ * unmapped name is more often a local src/icons/*.svg than a mistake. Runtime-
900
+ * computed names are listed with file and line so they can be declared, never
901
+ * guessed at.
902
+ */
903
+ async function cmdIcons(argv) {
904
+ const { values } = parseArgs({
905
+ args: argv,
906
+ options: {
907
+ site: {
908
+ type: "string",
909
+ default: "site.json"
910
+ },
911
+ src: {
912
+ type: "string",
913
+ default: "src"
914
+ },
915
+ sprite: {
916
+ type: "boolean",
917
+ default: false
918
+ },
919
+ out: {
920
+ type: "string",
921
+ short: "o",
922
+ default: "dist"
923
+ },
924
+ json: {
925
+ type: "boolean",
926
+ default: false
927
+ },
928
+ "site-env": {
929
+ type: "string",
930
+ default: "production"
931
+ }
932
+ },
933
+ allowPositionals: false
934
+ });
935
+ const sitePath = resolve(values.site);
936
+ const icons = existsSync(sitePath) ? (await loadSiteConfig(sitePath, values["site-env"])).icons ?? {} : {};
937
+ const ui = icons.ui ?? "fa7-solid";
938
+ const brand = icons.brand ?? "simple-icons";
939
+ const weight = icons.weight;
940
+ const weightOpt = weight ? { weight } : {};
941
+ let include = {};
942
+ try {
943
+ include = generateIconInclude({ ...icons });
944
+ } catch (e) {
945
+ fail(e.message);
946
+ }
947
+ const add = (prefix, name) => {
948
+ const list = include[prefix] ??= [];
949
+ if (!list.includes(name)) list.push(name);
950
+ };
951
+ const srcDir = resolve(values.src);
952
+ const scanned = existsSync(srcDir) ? collectIconRefs(scanFiles(srcDir)) : null;
953
+ const unmapped = [];
954
+ for (const name of scanned?.names ?? []) {
955
+ const colon = name.indexOf(":");
956
+ if (colon > 0) {
957
+ add(name.slice(0, colon), name.slice(colon + 1));
958
+ continue;
959
+ }
960
+ try {
961
+ const q = resolveIcon(name, ui, weightOpt);
962
+ add(q.slice(0, q.indexOf(":")), q.slice(q.indexOf(":") + 1));
963
+ } catch {
964
+ unmapped.push(name);
965
+ }
966
+ }
967
+ const dynamic = (scanned?.dynamic ?? []).map((d) => ({
968
+ file: relative(process.cwd(), d.file),
969
+ line: d.line,
970
+ expr: d.expr
971
+ }));
972
+ const total = Object.values(include).flat().length;
973
+ let sprite = null;
974
+ if (values.sprite) {
975
+ const aliases = {};
976
+ for (const name of icons.semantic ?? []) try {
977
+ aliases[`icon-${name}`] = resolveIcon(name, ui, weightOpt);
978
+ } catch {}
979
+ const built = await buildIconSprite({
980
+ include,
981
+ aliases
982
+ });
983
+ const outDir = resolve(values.out);
984
+ mkdirSync(outDir, { recursive: true });
985
+ writeFileSync(join(outDir, "icons.svg"), built.svg);
986
+ sprite = {
987
+ ids: built.ids,
988
+ missing: built.missing
989
+ };
990
+ }
991
+ if (values.json) process.stdout.write(`${JSON.stringify({
992
+ ui,
993
+ brand,
994
+ weight: weight ?? null,
995
+ include,
996
+ unmapped,
997
+ dynamic,
998
+ sprite
999
+ }, null, 2)}\n`);
1000
+ else {
1001
+ console.log(`icon sets: ui=${ui} brand=${brand}${weight ? ` weight=${weight}` : ""}`);
1002
+ for (const [prefix, names] of Object.entries(include)) console.log(` ${prefix}: ${names.length} — ${names.join(", ")}`);
1003
+ console.log(`✓ ${total} icon(s) across ${Object.keys(include).length} set(s)`);
1004
+ if (sprite) console.log(`✓ sprite → ${resolve(values.out)}/icons.svg (${sprite.ids.length} symbols)`);
1005
+ for (const d of dynamic) console.log(` ! ${d.file}:${d.line} ${d.expr} — computed at runtime, declare it in icons`);
1006
+ if (unmapped.length) console.log(` ! not in the '${ui}' map: ${unmapped.join(", ")} (fine for a local svg or sprite id)`);
1007
+ if (sprite?.missing.length) console.log(` ! absent from the sprite: ${sprite.missing.join(", ")}`);
1008
+ }
1009
+ if (sprite?.missing.length) process.exitCode = 1;
1010
+ }
1011
+ async function cmdLegal(argv) {
1012
+ const { values } = parseArgs({
1013
+ args: argv,
1014
+ options: {
1015
+ input: {
1016
+ type: "string",
1017
+ short: "i",
1018
+ default: "site.json"
1019
+ },
1020
+ doc: {
1021
+ type: "string",
1022
+ short: "d",
1023
+ multiple: true
1024
+ },
1025
+ format: {
1026
+ type: "string",
1027
+ short: "f",
1028
+ default: "md"
1029
+ },
1030
+ out: {
1031
+ type: "string",
1032
+ short: "o"
1033
+ },
1034
+ "site-env": {
1035
+ type: "string",
1036
+ default: "production"
1037
+ }
1038
+ },
1039
+ allowPositionals: false
1040
+ });
1041
+ const output = values.format;
1042
+ if (!LEGAL_OUTPUTS.has(output)) fail(`unknown format "${output}" (expected: ${[...LEGAL_OUTPUTS].join(" | ")})`);
1043
+ const requested = values.doc ?? [];
1044
+ for (const d of requested) if (!LEGAL_DOCS.has(d)) fail(`unknown doc "${d}" (expected: ${[...LEGAL_DOCS].join(" | ")})`);
1045
+ const site = await loadSiteConfig(resolve(values.input), values["site-env"]);
1046
+ const docs = requested.length ? requested : enabledDocs(site);
1047
+ if (docs.length === 0) fail("no legal documents are enabled — set legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your site config, or name one with --doc");
1048
+ const files = generateLegal(site, {
1049
+ docs,
1050
+ output
1051
+ });
1052
+ if (!values.out) {
1053
+ process.stdout.write(Object.values(files).join("\n"));
1054
+ return;
1055
+ }
1056
+ const outDir = resolve(values.out);
1057
+ mkdirSync(outDir, { recursive: true });
1058
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
1059
+ console.log(`✓ legal → ${outDir} (${Object.keys(files).length} files)`);
1060
+ console.log(" These are generated from your config and are not legal advice — review them before publishing.");
1061
+ }
446
1062
  function cliVersion() {
447
1063
  try {
448
1064
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
@@ -465,6 +1081,7 @@ function cmdDocs(argv) {
465
1081
  short: "i",
466
1082
  default: "design-system.json"
467
1083
  },
1084
+ theme: { type: "string" },
468
1085
  all: {
469
1086
  type: "boolean",
470
1087
  default: false
@@ -479,45 +1096,10 @@ function cmdDocs(argv) {
479
1096
  return;
480
1097
  }
481
1098
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
482
- const docs = generateDocs(loadConfig(resolve(values.input)), generatorAssetsDir());
1099
+ const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
483
1100
  const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
484
1101
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
485
1102
  }
486
- const SCAN_EXT = new Set([
487
- ".astro",
488
- ".html",
489
- ".htm",
490
- ".js",
491
- ".jsx",
492
- ".ts",
493
- ".tsx",
494
- ".vue",
495
- ".svelte",
496
- ".php",
497
- ".md",
498
- ".mdx"
499
- ]);
500
- const SCAN_SKIP = new Set([
501
- "node_modules",
502
- "dist",
503
- ".git",
504
- ".astro",
505
- "build",
506
- "coverage"
507
- ]);
508
- function scanFiles(dir, acc = []) {
509
- for (const e of readdirSync(dir, { withFileTypes: true })) {
510
- if (e.name.startsWith(".") && e.name !== ".") continue;
511
- const p = join(dir, e.name);
512
- if (e.isDirectory()) {
513
- if (!SCAN_SKIP.has(e.name)) scanFiles(p, acc);
514
- } else if (SCAN_EXT.has(extname(e.name))) acc.push({
515
- path: p,
516
- text: readFileSync(p, "utf8")
517
- });
518
- }
519
- return acc;
520
- }
521
1103
  function cmdLint(argv) {
522
1104
  const { values } = parseArgs({
523
1105
  args: argv,
@@ -527,6 +1109,7 @@ function cmdLint(argv) {
527
1109
  short: "i",
528
1110
  default: "design-system.json"
529
1111
  },
1112
+ theme: { type: "string" },
530
1113
  format: {
531
1114
  type: "string",
532
1115
  short: "f",
@@ -536,34 +1119,47 @@ function cmdLint(argv) {
536
1119
  type: "string",
537
1120
  short: "s",
538
1121
  default: "src"
1122
+ },
1123
+ strict: {
1124
+ type: "boolean",
1125
+ default: false
539
1126
  }
540
1127
  },
541
1128
  allowPositionals: false
542
1129
  });
543
1130
  const format = values.format;
544
- if (!FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
545
- const ds = loadConfig(resolve(values.input));
1131
+ if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
1132
+ const ds = loadConfig(resolve(values.input), values.theme);
546
1133
  const srcDir = resolve(values.src);
547
1134
  if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
548
- const roleClasses = roleColorUtilities(Object.entries(ds.colors.roles).map(([role, hue]) => functionalRole(role, hue, expandPalette(ds.colors.palette)[hue])), ds.colors.utilities ?? [
1135
+ const palette = expandPalette(ds.colors.palette);
1136
+ const roleClasses = roleColorUtilities(Object.entries(ds.colors.roles).map(([role, spec]) => functionalRole(role, roleHue(spec), palette[roleHue(spec)], roleKind(spec))), ds.colors.utilities ?? [
549
1137
  "bg",
550
1138
  "text",
1139
+ "icon",
551
1140
  "border"
552
1141
  ]).map((u) => u.cls);
553
1142
  const files = scanFiles(srcDir);
554
- const findings = lintSource(files, vocabulary(ds, roleClasses), format);
1143
+ const cssFiles = [...files, ...scanFiles(srcDir, { exts: [".css"] })];
1144
+ const findings = [...lintSource(files, vocabulary(ds, roleClasses), format), ...lintCss(cssFiles, format)];
555
1145
  if (!findings.length) {
556
1146
  console.log(`✓ no unresolvable framework classes in ${values.src} (${files.length} file${files.length === 1 ? "" : "s"} scanned)`);
557
1147
  return;
558
1148
  }
559
- for (const f of findings) {
560
- console.error(`${f.file}:${f.line} ${f.cls}`);
1149
+ const errors = findings.filter((f) => f.severity === "error");
1150
+ const suggestions = findings.filter((f) => f.severity === "suggestion");
1151
+ for (const f of [...errors, ...suggestions]) {
1152
+ console.error(`${f.file}:${f.line} ${f.severity === "error" ? f.cls : `(${f.cls})`}`);
561
1153
  console.error(` ${f.reason}`);
562
1154
  if (f.suggestion) console.error(` try: ${f.suggestion}`);
563
1155
  }
564
- const n = findings.length;
565
- console.error(`\n✖ ${n} unresolvable class${n === 1 ? "" : "es"} in ${files.length} file${files.length === 1 ? "" : "s"}`);
566
- process.exit(1);
1156
+ const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
1157
+ const parts = [];
1158
+ if (errors.length) parts.push(count(errors.length, "unresolvable class", "unresolvable classes"));
1159
+ if (suggestions.length) parts.push(count(suggestions.length, "suggestion", "suggestions"));
1160
+ const failing = errors.length > 0 || values.strict === true && suggestions.length > 0;
1161
+ console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${files.length} file${files.length === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
1162
+ if (failing) process.exit(1);
567
1163
  }
568
1164
  function cmdAgents(argv) {
569
1165
  const { values } = parseArgs({
@@ -574,6 +1170,7 @@ function cmdAgents(argv) {
574
1170
  short: "i",
575
1171
  default: "design-system.json"
576
1172
  },
1173
+ theme: { type: "string" },
577
1174
  out: {
578
1175
  type: "string",
579
1176
  short: "o",
@@ -585,7 +1182,7 @@ function cmdAgents(argv) {
585
1182
  });
586
1183
  const inputRel = values.input;
587
1184
  let ds = null;
588
- if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel));
1185
+ if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
589
1186
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
590
1187
  const legacyDocsDir = values["docs-dir"];
591
1188
  let summary;
@@ -641,6 +1238,7 @@ function cmdAgents(argv) {
641
1238
  "- `vitops generate --format tailwind --out src/styles` — Tailwind v4 / Astro",
642
1239
  "- `vitops generate --format css --out dist` — standalone CSS",
643
1240
  "- `vitops generate --format bricks --out <theme>/dist` — WordPress / Bricks",
1241
+ "- `vitops generate --format design --out .` — `DESIGN.md`, the agent-facing brief",
644
1242
  "- `vitops init` · `vitops validate` · `vitops favicon`",
645
1243
  "",
646
1244
  ...pointer,
@@ -684,7 +1282,9 @@ async function main() {
684
1282
  case "agents": return cmdAgents(rest);
685
1283
  case "docs": return cmdDocs(rest);
686
1284
  case "lint": return cmdLint(rest);
687
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint). Try: vitops --help`);
1285
+ case "legal": return cmdLegal(rest);
1286
+ case "icons": return cmdIcons(rest);
1287
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons). Try: vitops --help`);
688
1288
  }
689
1289
  }
690
1290
  main().catch((err) => fail(err.message));