living-docs-kit 0.1.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 (33) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +85 -0
  3. package/agents/docs-reviewer.md +33 -0
  4. package/agents/docs-writer.md +31 -0
  5. package/bin/cli.mjs +68 -0
  6. package/package.json +21 -0
  7. package/skills/docs-design/SKILL.md +91 -0
  8. package/skills/docs-design/references/theme-schema.md +108 -0
  9. package/skills/docs-guide/SKILL.md +83 -0
  10. package/skills/docs-init/SKILL.md +136 -0
  11. package/skills/docs-init/assets/DOCS-GUIDE.template.md +81 -0
  12. package/skills/docs-init/assets/agents-snippet.md +7 -0
  13. package/skills/docs-init/assets/deploy-github-pages.yml +43 -0
  14. package/skills/docs-init/assets/site-template/content/index.md +4 -0
  15. package/skills/docs-init/assets/site-template/docs.config.json +36 -0
  16. package/skills/docs-init/assets/site-template/engine/assets/app.js +414 -0
  17. package/skills/docs-init/assets/site-template/engine/assets/base.css +229 -0
  18. package/skills/docs-init/assets/site-template/engine/build.mjs +432 -0
  19. package/skills/docs-init/assets/site-template/engine/check.mjs +100 -0
  20. package/skills/docs-init/assets/site-template/engine/dev.mjs +49 -0
  21. package/skills/docs-init/assets/site-template/engine/facts.mjs +157 -0
  22. package/skills/docs-init/assets/site-template/engine/i18n.mjs +99 -0
  23. package/skills/docs-init/assets/site-template/engine/lib.mjs +368 -0
  24. package/skills/docs-init/assets/site-template/engine/palette.mjs +149 -0
  25. package/skills/docs-init/assets/site-template/engine/theme-tool.mjs +201 -0
  26. package/skills/docs-init/assets/site-template/engine/vendor/mermaid.min.js +3636 -0
  27. package/skills/docs-init/assets/site-template/package.json +24 -0
  28. package/skills/docs-init/assets/site-template/themes/atlas.json +40 -0
  29. package/skills/docs-init/assets/site-template/themes/fjord.json +40 -0
  30. package/skills/docs-init/assets/site-template/themes/graphite.json +45 -0
  31. package/skills/docs-write/SKILL.md +109 -0
  32. package/skills/docs-write/references/authoring.md +125 -0
  33. package/skills/docs-write/references/page-types.md +104 -0
@@ -0,0 +1,149 @@
1
+ // Extracts raw design material (colours, fonts) that the agent then maps onto theme tokens.
2
+ //
3
+ // node engine/palette.mjs --url https://example.com CSS variables, colours by usage, fonts
4
+ // node engine/palette.mjs --image ./palette.png dominant colours (path or http(s) URL)
5
+ // node engine/palette.mjs --image shot.png --colors 10
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { rgbToHex, luminance, contrast } from './lib.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+ const opt = (n) => { const i = args.indexOf('--' + n); return i > -1 ? args[i + 1] : undefined; };
13
+ const UA = { 'User-Agent': 'Mozilla/5.0 (living-docs palette extractor)' };
14
+
15
+ function toHex(v) {
16
+ v = v.trim().toLowerCase();
17
+ let m;
18
+ if ((m = v.match(/^#([0-9a-f]{3,8})$/))) {
19
+ let h = m[1];
20
+ if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split('').map((c) => c + c).join('');
21
+ if (h.length === 8) { if (parseInt(h.slice(6), 16) < 128) return null; h = h.slice(0, 6); }
22
+ return h.length === 6 ? '#' + h : null;
23
+ }
24
+ if ((m = v.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/))) {
25
+ if (m[4] !== undefined && parseFloat(m[4]) < (m[4].endsWith('%') ? 50 : 0.5)) return null;
26
+ return rgbToHex([+m[1], +m[2], +m[3]]);
27
+ }
28
+ if ((m = v.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/))) {
29
+ const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100;
30
+ const f = (n) => { const k = (n + h * 12) % 12; const a = s * Math.min(l, 1 - l); return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
31
+ return rgbToHex([f(0) * 255, f(8) * 255, f(4) * 255]);
32
+ }
33
+ const named = { white: '#ffffff', black: '#000000' };
34
+ return named[v] || null;
35
+ }
36
+
37
+ async function get(url) {
38
+ const r = await fetch(url, { headers: UA, redirect: 'follow' });
39
+ if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
40
+ return r.text();
41
+ }
42
+
43
+ async function fromUrl(url) {
44
+ const html = await get(url);
45
+ const css = [];
46
+ for (const m of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) css.push(m[1]);
47
+ const links = [...html.matchAll(/<link[^>]+>/gi)].map((m) => m[0]).filter((l) => /rel=["']?stylesheet/i.test(l));
48
+ const hrefs = links.map((l) => (l.match(/href=["']([^"']+)["']/i) || [])[1]).filter(Boolean);
49
+ const fontLinks = hrefs.filter((h) => /fonts\.googleapis\.com|fonts\.bunny\.net|use\.typekit\.net|fonts\.adobe\.com/.test(h));
50
+ for (const h of hrefs.filter((x) => !fontLinks.includes(x)).slice(0, 10)) {
51
+ try { css.push(await get(new URL(h, url).href)); } catch (e) { console.error('! could not read ' + h + ': ' + e.message); }
52
+ }
53
+ for (const m of html.matchAll(/style=["']([^"']+)["']/gi)) css.push(`x{${m[1]}}`);
54
+ const all = css.join('\n');
55
+
56
+ const vars = {};
57
+ for (const m of all.matchAll(/(--[\w-]+)\s*:\s*([^;}{]+)/g)) {
58
+ const hex = toHex(m[2]);
59
+ if (hex && !vars[m[1]]) vars[m[1]] = hex;
60
+ }
61
+ const usage = {};
62
+ const bump = (hex, role) => { if (!hex) return; usage[hex] ??= { hex, total: 0, background: 0, text: 0, border: 0, other: 0 }; usage[hex].total++; usage[hex][role]++; };
63
+ for (const m of all.matchAll(/([\w-]+)\s*:\s*([^;}{]+)/g)) {
64
+ const prop = m[1].toLowerCase();
65
+ if (prop.startsWith('--')) continue;
66
+ const role = /background/.test(prop) ? 'background' : prop === 'color' ? 'text' : /border|outline/.test(prop) ? 'border' : /fill|stroke|shadow|gradient/.test(prop) ? 'other' : null;
67
+ if (!role) continue;
68
+ for (const c of m[2].matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\bwhite\b|\bblack\b/g)) bump(toHex(c[0]), role);
69
+ const v = m[2].match(/var\((--[\w-]+)/);
70
+ if (v && vars[v[1]]) bump(vars[v[1]], role);
71
+ }
72
+ const fonts = {};
73
+ for (const m of all.matchAll(/font-family\s*:\s*([^;}{]+)/gi)) {
74
+ const f = m[1].trim().replace(/\s*!important/, '');
75
+ if (!f.startsWith('var(') && !/inherit|initial/.test(f)) fonts[f] = (fonts[f] || 0) + 1;
76
+ }
77
+ const fontVars = {};
78
+ for (const m of all.matchAll(/(--[\w-]*font[\w-]*)\s*:\s*([^;}{]+)/gi)) fontVars[m[1]] ||= m[2].trim();
79
+ const colors = Object.values(usage).sort((a, b) => b.total - a.total).slice(0, 24).map((c) => ({ ...c, luminance: +luminance(c.hex).toFixed(3) }));
80
+ return {
81
+ source: url,
82
+ themeColorMeta: (html.match(/<meta[^>]+name=["']theme-color["'][^>]+content=["']([^"']+)/i) || [])[1] || null,
83
+ hasDarkModeCss: /prefers-color-scheme:\s*dark/.test(all),
84
+ cssVariables: vars,
85
+ colorsByUsage: colors,
86
+ fonts: Object.entries(fonts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([family, count]) => ({ family, count })),
87
+ fontVariables: fontVars,
88
+ fontStylesheets: fontLinks,
89
+ note: colors.length < 3 ? 'Very few colours found (site rendered by JavaScript?). Take a screenshot and run --image on it.' : undefined,
90
+ };
91
+ }
92
+
93
+ async function fromImage(src, k) {
94
+ let Jimp;
95
+ try { ({ Jimp } = await import('jimp')); } catch { throw new Error('package "jimp" is missing: run npm install in this folder'); }
96
+ let file = src;
97
+ if (/^https?:\/\//.test(src)) {
98
+ const r = await fetch(src, { headers: UA });
99
+ if (!r.ok) throw new Error(`${src} → HTTP ${r.status}`);
100
+ file = path.join(os.tmpdir(), 'ld-palette-' + Date.now() + path.extname(new URL(src).pathname || '.png'));
101
+ fs.writeFileSync(file, Buffer.from(await r.arrayBuffer()));
102
+ }
103
+ const img = await Jimp.read(file);
104
+ img.resize({ w: 160 });
105
+ const { data, width, height } = img.bitmap;
106
+ const px = [];
107
+ for (let i = 0; i < width * height; i++) {
108
+ if (data[i * 4 + 3] < 128) continue;
109
+ px.push([data[i * 4], data[i * 4 + 1], data[i * 4 + 2]]);
110
+ }
111
+ // k-means++ in RGB
112
+ const dist = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
113
+ let centers = [px[Math.floor(px.length / 2)]];
114
+ while (centers.length < k) {
115
+ const d = px.map((p) => Math.min(...centers.map((c) => dist(p, c))));
116
+ const sum = d.reduce((a, b) => a + b, 0);
117
+ let r = Math.random() * sum;
118
+ let idx = 0;
119
+ while ((r -= d[idx]) > 0 && idx < d.length - 1) idx++;
120
+ centers.push(px[idx]);
121
+ }
122
+ let assign = new Array(px.length).fill(0);
123
+ for (let it = 0; it < 12; it++) {
124
+ assign = px.map((p) => { let best = 0, bd = Infinity; centers.forEach((c, j) => { const dd = dist(p, c); if (dd < bd) { bd = dd; best = j; } }); return best; });
125
+ centers = centers.map((c, j) => {
126
+ const mine = px.filter((_, i) => assign[i] === j);
127
+ if (!mine.length) return c;
128
+ return [0, 1, 2].map((ch) => mine.reduce((a, p) => a + p[ch], 0) / mine.length);
129
+ });
130
+ }
131
+ const counts = centers.map((_, j) => assign.filter((a) => a === j).length);
132
+ const out = centers
133
+ .map((c, j) => ({ hex: rgbToHex(c), share: +(counts[j] / px.length * 100).toFixed(1) }))
134
+ .filter((c) => c.share > 0.5)
135
+ .sort((a, b) => b.share - a.share)
136
+ .map((c) => ({ ...c, luminance: +luminance(c.hex).toFixed(3), contrastOnWhite: +contrast(c.hex, '#ffffff').toFixed(2), contrastOnBlack: +contrast(c.hex, '#000000').toFixed(2) }));
137
+ return { source: src, dominantColors: out, note: 'Colours are ordered by area. The lightest/darkest are candidates for background/text; saturated ones for accent and links.' };
138
+ }
139
+
140
+ try {
141
+ const url = opt('url');
142
+ const image = opt('image');
143
+ if (!url && !image) throw new Error('usage: --url <site> | --image <path or URL> [--colors 8]');
144
+ const result = url ? await fromUrl(url) : await fromImage(image, Math.max(3, Math.min(16, +(opt('colors') || 8))));
145
+ console.log(JSON.stringify(result, null, 2));
146
+ } catch (e) {
147
+ console.error('✗ ' + e.message);
148
+ process.exit(1);
149
+ }
@@ -0,0 +1,201 @@
1
+ // Theme management. Themes live in themes/<id>.json; the default is "defaultTheme" in docs.config.json.
2
+ //
3
+ // node engine/theme-tool.mjs list
4
+ // node engine/theme-tool.mjs show <id>
5
+ // node engine/theme-tool.mjs validate [id] schema + WCAG contrast (exit 1 on errors)
6
+ // node engine/theme-tool.mjs new <id> --from <base> [--label "Nume"]
7
+ // node engine/theme-tool.mjs set <id> <path> <value> e.g. set fjord typography.baseSize 18
8
+ // node engine/theme-tool.mjs fix <id> adjust failing colours (lightness only) until contrast passes
9
+ // node engine/theme-tool.mjs set-default <id>
10
+ // node engine/theme-tool.mjs remove <id>
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { THEMES_DIR, loadConfig, saveConfig, loadThemes, resolveTheme, contrast, hexToRgb, mix, REQUIRED_COLORS, OPTIONAL_COLORS } from './lib.mjs';
14
+
15
+ const [cmd, ...args] = process.argv.slice(2);
16
+ const opt = (name) => { const i = args.indexOf('--' + name); return i > -1 ? args[i + 1] : undefined; };
17
+ const fileOf = (id) => path.join(THEMES_DIR, id + '.json');
18
+ const readTheme = (id) => {
19
+ if (!fs.existsSync(fileOf(id))) throw new Error(`theme "${id}" does not exist. Themes: ${loadThemes().map((t) => t.id).join(', ')}`);
20
+ return JSON.parse(fs.readFileSync(fileOf(id), 'utf8'));
21
+ };
22
+ const writeTheme = (t) => fs.writeFileSync(fileOf(t.id), JSON.stringify(t, null, 2) + '\n');
23
+
24
+ // [foreground, background, minimum ratio, what]
25
+ const PAIRS = [
26
+ ['text', 'bg', 4.5, 'body text on background'],
27
+ ['text', 'surface', 4.5, 'text on surfaces (tables, boxes)'],
28
+ ['textMuted', 'bg', 4.5, 'secondary text on background'],
29
+ ['textMuted', 'surface', 3.0, 'secondary text on surfaces'],
30
+ ['heading', 'bg', 4.5, 'headings on background'],
31
+ ['link', 'bg', 4.5, 'links on background'],
32
+ ['link', 'surface', 4.5, 'links on surfaces'],
33
+ ['accentText', 'accent', 4.5, 'text on the accent colour'],
34
+ ['codeText', 'codeBg', 4.5, 'code on code background'],
35
+ ['text', 'highlight', 4.5, 'text on highlight (search, current page)'],
36
+ ['warning', 'bg', 3.0, 'the "possibly outdated" badge'],
37
+ ['success', 'bg', 3.0, 'the "verified" badge'],
38
+ ['danger', 'bg', 3.0, 'warnings'],
39
+ ['accent', 'bg', 3.0, 'accent visible on background (bars, active borders)'],
40
+ ];
41
+
42
+ function validate(raw) {
43
+ const errors = [];
44
+ const warnings = [];
45
+ if (!/^[a-z0-9-]+$/.test(raw.id || '')) errors.push('id must contain only lowercase letters, digits and hyphens');
46
+ if (!raw.label) warnings.push('missing "label" (the name shown in the theme picker)');
47
+ if (!['light', 'dark'].includes(raw.mode)) errors.push('"mode" must be "light" or "dark"');
48
+ const c = raw.colors || {};
49
+ for (const k of REQUIRED_COLORS) if (!c[k]) errors.push(`missing required colour colors.${k}`);
50
+ for (const [k, v] of Object.entries(c)) {
51
+ if (!REQUIRED_COLORS.includes(k) && !OPTIONAL_COLORS.includes(k)) warnings.push(`colors.${k} is not used by the site`);
52
+ if (!hexToRgb(v)) errors.push(`colors.${k} = "${v}" is not hex (#rrggbb)`);
53
+ }
54
+ for (const [k, v] of Object.entries(raw.diagram || {})) if (!hexToRgb(v)) errors.push(`diagram.${k} = "${v}" is not hex`);
55
+ if (errors.length) return { errors, warnings, ratios: [] };
56
+ const t = resolveTheme(raw);
57
+ const ratios = PAIRS.map(([fg, bg, min, what]) => {
58
+ const r = contrast(t.colors[fg], t.colors[bg]);
59
+ const ok = r >= min;
60
+ (ok ? [] : min >= 4.5 ? errors : warnings).push(`contrast ${fg}/${bg} = ${r.toFixed(2)} < ${min} (${what})`);
61
+ return { pair: `${fg}/${bg}`, ratio: +r.toFixed(2), min, ok };
62
+ });
63
+ const dNode = contrast(t.diagram.nodeText, t.diagram.nodeBg);
64
+ if (dNode < 4.5) errors.push(`contrast diagram.nodeText/nodeBg = ${dNode.toFixed(2)} < 4.5 (text inside diagram nodes)`);
65
+ const bs = Number(t.typography.baseSize);
66
+ if (!(bs >= 12 && bs <= 28)) errors.push(`typography.baseSize = ${t.typography.baseSize}; use a pixel number between 12 and 28`);
67
+ const lh = Number(t.typography.lineHeight);
68
+ if (!(lh >= 1.2 && lh <= 2.2)) warnings.push(`typography.lineHeight = ${lh}; 1.5–1.8 is comfortable for reading`);
69
+ if (t.fonts.googleFontsUrl && !/^https:\/\/fonts\.googleapis\.com\/css2\?/.test(t.fonts.googleFontsUrl)) warnings.push('fonts.googleFontsUrl does not look like a Google Fonts css2 URL');
70
+ for (const k of ['body', 'heading', 'mono']) {
71
+ if (t.fonts[k] && !/(sans-serif|serif|monospace|system-ui)\s*$/.test(t.fonts[k])) warnings.push(`fonts.${k} does not end with a generic family (sans-serif/serif/monospace)`);
72
+ }
73
+ if (t.extraCss && !t.extraCss.includes(`[data-theme="${t.id}"]`)) warnings.push(`extraCss should be scoped with :root[data-theme="${t.id}"] so it does not affect other themes`);
74
+ return { errors, warnings, ratios };
75
+ }
76
+
77
+ function setPath(obj, p, value) {
78
+ const keys = p.split('.');
79
+ let o = obj;
80
+ for (const k of keys.slice(0, -1)) o = o[k] ??= {};
81
+ let v = value;
82
+ if (/^-?\d+(\.\d+)?$/.test(value)) v = Number(value);
83
+ else if (value === 'true' || value === 'false') v = value === 'true';
84
+ else if (value === 'null') v = undefined;
85
+ if (v === undefined) delete o[keys.at(-1)];
86
+ else o[keys.at(-1)] = v;
87
+ }
88
+
89
+ try {
90
+ const cfg = loadConfig();
91
+ switch (cmd) {
92
+ case 'list': {
93
+ for (const t of loadThemes()) console.log(`${t.id === cfg.defaultTheme ? '★' : ' '} ${t.id.padEnd(22)} ${String(t.label || '').padEnd(26)} ${t.mode} ${t.source ? '← ' + t.source : ''}`);
94
+ console.log('\n★ = default (docs.config.json → defaultTheme)');
95
+ break;
96
+ }
97
+ case 'show':
98
+ console.log(JSON.stringify(resolveTheme(readTheme(args[0])), null, 2));
99
+ break;
100
+ case 'validate': {
101
+ const list = args[0] ? [readTheme(args[0])] : loadThemes();
102
+ let bad = 0;
103
+ for (const t of list) {
104
+ const r = validate(t);
105
+ console.log(`\n${r.errors.length ? '✗' : '✓'} ${t.id}`);
106
+ for (const e of r.errors) console.log(' ✗ ' + e);
107
+ for (const w of r.warnings) console.log(' ! ' + w);
108
+ if (!r.errors.length) console.log(' contrast: ' + r.ratios.map((x) => `${x.pair} ${x.ratio}`).join(' · '));
109
+ if (r.errors.length) bad++;
110
+ }
111
+ if (!list.some((t) => t.id === cfg.defaultTheme)) { console.log(`\n✗ defaultTheme "${cfg.defaultTheme}" does not exist`); bad++; }
112
+ process.exit(bad ? 1 : 0);
113
+ }
114
+ case 'new': {
115
+ const [id] = args;
116
+ const base = opt('from');
117
+ if (!id || !base) throw new Error('usage: new <id> --from <existing-theme> [--label "Name"]');
118
+ if (fs.existsSync(fileOf(id))) throw new Error(`tema "${id}" already exists`);
119
+ const t = { ...readTheme(base), id, label: opt('label') || id, source: `derived from ${base}` };
120
+ delete t._file;
121
+ writeTheme(t);
122
+ console.log(`✓ created themes/${id}.json from ${base}. Edit the values, then run: validate ${id}`);
123
+ break;
124
+ }
125
+ case 'set': {
126
+ const [id, p, value] = args;
127
+ if (!id || !p || value === undefined) throw new Error('usage: set <id> <path.to.property> <value>');
128
+ const t = readTheme(id);
129
+ setPath(t, p, value);
130
+ const r = validate(t);
131
+ if (r.errors.length) throw new Error('this change would make the theme invalid:\n ' + r.errors.join('\n '));
132
+ writeTheme(t);
133
+ console.log(`✓ ${id}.${p} = ${value}`);
134
+ break;
135
+ }
136
+ case 'fix': {
137
+ const [id] = args;
138
+ const t = readTheme(id);
139
+ t.colors ||= {};
140
+ const dark = t.mode === 'dark';
141
+ const changes = [];
142
+ for (let round = 0; round < 3; round++) {
143
+ const r = resolveTheme(t);
144
+ for (const [fg, bg, min] of PAIRS) {
145
+ let ratio = contrast(r.colors[fg], r.colors[bg]);
146
+ if (ratio >= min) continue;
147
+ if (fg === 'accentText') {
148
+ // text on accent: pick white or near-black; if neither passes, darken/lighten the accent itself
149
+ const best = contrast('#ffffff', r.colors.accent) >= contrast('#111111', r.colors.accent) ? '#ffffff' : '#111111';
150
+ let acc = r.colors.accent;
151
+ const accStart = acc;
152
+ for (let i = 1; i <= 50 && contrast(best, acc) < min + 0.15; i++) acc = mix(accStart, best === '#ffffff' ? '#000000' : '#ffffff', i * 0.02);
153
+ if (acc !== accStart) { t.colors.accent = acc; r.colors.accent = acc; changes.push(`accent: ${accStart} → ${acc}`); }
154
+ changes.push(`accentText: ${r.colors.accentText} → ${best} (${contrast(best, acc).toFixed(2)})`);
155
+ t.colors.accentText = best;
156
+ r.colors.accentText = best;
157
+ continue;
158
+ }
159
+ // move the foreground away from the background; for highlight, move the background toward the page bg
160
+ const target = fg === 'text' && bg === 'highlight' ? 'highlight' : fg;
161
+ const toward = target === 'highlight' ? r.colors.bg : (dark ? '#ffffff' : '#000000');
162
+ let value = r.colors[target];
163
+ const start = value;
164
+ for (let i = 1; i <= 50 && ratio < min + 0.15; i++) {
165
+ value = mix(start, toward, i * 0.02);
166
+ ratio = target === 'highlight' ? contrast(r.colors[fg], value) : contrast(value, r.colors[bg]);
167
+ }
168
+ t.colors[target] = value;
169
+ r.colors[target] = value;
170
+ changes.push(`${target}: ${start} → ${value} (${fg}/${bg} ${ratio.toFixed(2)})`);
171
+ }
172
+ }
173
+ writeTheme(t);
174
+ console.log(changes.length ? '✓ adjusted:\n ' + changes.join('\n ') : '✓ nothing to adjust');
175
+ const v = validate(t);
176
+ if (v.errors.length) { console.log('✗ problems remain:\n ' + v.errors.join('\n ')); process.exit(1); }
177
+ break;
178
+ }
179
+ case 'set-default': {
180
+ const [id] = args;
181
+ readTheme(id);
182
+ saveConfig((c) => { c.defaultTheme = id; });
183
+ console.log(`✓ default theme is now "${id}"`);
184
+ break;
185
+ }
186
+ case 'remove': {
187
+ const [id] = args;
188
+ if (id === cfg.defaultTheme) throw new Error('cannot remove the default theme; choose another one with set-default first');
189
+ readTheme(id);
190
+ fs.unlinkSync(fileOf(id));
191
+ console.log(`✓ removed themes/${id}.json`);
192
+ break;
193
+ }
194
+ default:
195
+ console.log('commands: list | show <id> | validate [id] | fix <id> | new <id> --from <base> | set <id> <path> <value> | set-default <id> | remove <id>');
196
+ process.exit(cmd ? 1 : 0);
197
+ }
198
+ } catch (e) {
199
+ console.error('✗ ' + e.message);
200
+ process.exit(1);
201
+ }