Repository navigation
Expand file tree
/
Copy pathcli.js
More file actions
executable file
·478 lines (437 loc) · 21.3 KB
/
Copy pathcli.js
File metadata and controls
executable file
·478 lines (437 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
#!/usr/bin/env node
/**
* ux-ui-agent-skills installer
*
* Copies the design-system skill kit into a target project.
* Zero dependencies: Node built-ins only.
*
* npx ux-ui-agent-skills@3.1.0 init [dest] Install the full kit (default dest: cwd)
* npx ux-ui-agent-skills@3.1.0 new [dest] Scaffold a NEW product repo from the starter template
* npx ux-ui-agent-skills@3.1.0 add <area>... Install specific areas
* npx ux-ui-agent-skills@3.1.0 list List available areas
* npx ux-ui-agent-skills@3.1.0 library get <name> Fetch one design-system spec into this project
* npx ux-ui-agent-skills@3.1.0 version Print the installed version
* npx ux-ui-agent-skills@3.1.0 help
*
* Flags: --force (overwrite existing files), --dry (print actions only)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
/* Commands the CLI prints are pinned to the version that is running, so a
reader who copies one gets this release, never whatever is newest on npm. */
const RUN = `npx ux-ui-agent-skills@${require(path.join(ROOT, 'package.json')).version}`;
// area -> source path (relative to package root)
const AREAS = {
claude: 'CLAUDE.md',
// The open AGENTS.md convention: Codex, Cursor, Copilot, Jules, Aider, VS Code
// and ~25 more read it. Same doctrine as CLAUDE.md, minus what only Claude Code
// can enforce. --agent picks which one lands.
agentsmd: 'AGENTS.md',
tokens: 'tokens',
components: 'components',
taste: 'taste',
'design-systems': 'design-systems',
frameworks: 'frameworks',
accessibility: 'accessibility',
workflows: 'workflows',
content: 'content',
scripts: 'scripts',
skills: '.claude/skills',
rules: '.claude/rules',
// Added 2026-10-05. Without these three an initialised project had no
// /critique and no design-critic (the skills delegate to an agent that was
// never copied), and every skill that holds up examples/golden/ or
// examples/component-states/ as the quality bar pointed at nothing.
agents: '.claude/agents',
examples: 'examples',
templates: 'templates',
};
// Counted, never typed. This line read "10 runnable Claude skills" while 19 were
// installed, because a number in a string has nothing holding it to the folder.
// Counted, never typed - the same reason skillCount() exists. These read "13 DTCG
// token files" with 14 present, and "42 component specs" with 52.
function count(dir, ext) {
try {
return fs.readdirSync(path.join(ROOT, dir)).filter(f => f.endsWith(ext)).length;
} catch {
return 0;
}
}
function componentCount() {
try {
return fs.readdirSync(path.join(ROOT, 'components'))
.filter(f => f.endsWith('.md'))
.reduce((n, f) => n + (fs.readFileSync(path.join(ROOT, 'components', f), 'utf8')
.match(/^## \d+\. /gm) || []).length, 0);
} catch {
return 0;
}
}
function skillCount() {
try {
return fs.readdirSync(path.join(ROOT, '.claude', 'skills'), { withFileTypes: true })
.filter(d => d.isDirectory()).length;
} catch {
return 0;
}
}
// The library size was typed here as 138 while 2,144 shipped. Read from the
// index, because an npm install carries no spec until one is fetched.
function systemCount() {
try {
return Object.keys(JSON.parse(fs.readFileSync(
path.join(ROOT, 'design-systems', 'library.json'), 'utf8')).systems).length;
} catch {
return 0;
}
}
/* Never copied into a project. 2,144 specs are 26MB, and a project uses one or
two: `library get <name>` fetches those, `library pull` fetches the rest. A
clone carries the folder and the npm package does not; skipping it here means
init behaves the same from either. */
const NEVER_COPIED = [path.join(ROOT, 'design-systems', 'library')];
const DESC = {
claude: 'CLAUDE.md: agent persona & master instructions (+ Request Router)',
agentsmd: 'AGENTS.md: the same doctrine for Codex, Cursor, Copilot and ~25 more',
tokens: `${count('tokens', '.json')} DTCG token files (colors, type, spacing, motion, theming…)`,
components: `${componentCount()} component specs (atoms → templates + nav/feedback/forms/overlays)`,
taste: 'Anti-slop doctrine, aesthetic archetypes, motion choreography',
'design-systems': `Interop protocol + crosswalk + ${systemCount().toLocaleString('en-US')}-system library (index; specs fetched on use)`,
frameworks: 'Adapter Protocol + React/Next/SwiftUI + 10 concise adapters',
accessibility: 'WCAG 2.2 checklist + ARIA patterns',
workflows: 'Design review, handoff, prototyping, redesign-audit',
content: 'Voice & tone / UX writing system',
scripts: 'validate_tokens · contrast · design_systems · scaffold_component',
skills: `${skillCount()} runnable Claude skills (.claude/skills/)`,
rules: 'Depth split out of CLAUDE.md, loaded on demand (.claude/rules/)',
agents: 'design-critic: the adversarial reviewer behind /critique',
examples: 'The gated reference output every skill points at as the bar',
templates: 'Starter layout /scaffold-project generates a new project from',
};
const C = {
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
green: '\x1b[32m', cyan: '\x1b[36m', yellow: '\x1b[33m', red: '\x1b[31m',
};
function parseFlags(argv) {
// agent: which instruction surface to install.
// claude -> CLAUDE.md + .claude/ (skills, rules, the critic agent). Hooks come
// only with the plugin install; init writes no hooks and no settings.
// codex -> AGENTS.md, read by Codex, Cursor, Copilot, Jules, Aider and more
// both -> both files; they are held in step by validate_agents_surface.py
// gemini -> GEMINI.md + .agents/skills/, read by Gemini CLI and Antigravity
const flags = { force: false, dry: false, noOpen: false, agent: 'claude' };
const rest = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--force' || a === '-f') flags.force = true;
else if (a === '--dry' || a === '--dry-run') flags.dry = true;
else if (a === '--no-open') flags.noOpen = true;
else if (a === '--agent' || a.startsWith('--agent=')) {
const v = a.includes('=') ? a.split('=')[1] : argv[++i];
const ok = ['claude', 'codex', 'agents', 'both', 'gemini', 'antigravity'];
if (!ok.includes(v)) {
console.error(`${C.red}--agent must be one of:${C.reset} ${ok.join(', ')}\n`);
process.exit(1);
}
flags.agent = v === 'agents' ? 'codex' : v === 'antigravity' ? 'gemini' : v;
}
/* An unrecognised flag used to fall through into `rest` and be read as a
destination path, so a typo like --forse silently created a directory
called "--forse". Say so instead. */
else if (a.startsWith('-')) {
console.error(`${C.red}unknown flag:${C.reset} ${a}\n`);
help();
process.exit(1);
}
else rest.push(a);
}
return { flags, rest };
}
let copied = 0;
let skipped = 0;
/* Write one generated file under the same rules a copy follows. */
function writeGenerated(dest, text, flags) {
if (fs.existsSync(dest) && !flags.force) {
skipped++;
console.log(` ${C.yellow}skip${C.reset} ${path.relative(process.cwd(), dest)} ${C.dim}(exists, use --force)${C.reset}`);
return;
}
if (!flags.dry) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, text);
}
copied++;
console.log(` ${C.green}+${C.reset} ${path.relative(process.cwd(), dest)}`);
}
/* Gemini CLI and Antigravity read GEMINI.md and .agents/skills/, and expand no
${CLAUDE_SKILL_DIR}, so the brief and each skill are ported on the way out:
paths made relative to the project root, Claude-only skills left behind. */
function installGemini(destRoot, flags) {
const { portSkill, geminiBrief, CLAUDE_ONLY } = require('./port-skills.js');
writeGenerated(path.join(destRoot, 'GEMINI.md'),
geminiBrief(fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf8'), 'workspace'), flags);
const src = path.join(ROOT, '.claude', 'skills');
for (const name of fs.readdirSync(src).sort()) {
const file = path.join(src, name, 'SKILL.md');
if (name in CLAUDE_ONLY || !fs.existsSync(file)) continue;
writeGenerated(path.join(destRoot, '.agents', 'skills', name, 'SKILL.md'),
portSkill(fs.readFileSync(file, 'utf8'), ''), flags);
}
}
/* Build litter a clone accumulates and the npm tarball already excludes (the
same negations as `files` in package.json). Run from a clone, init copied
twelve scripts/__pycache__ files into the user's project. */
const LITTER = new Set(['__pycache__', '.DS_Store']);
function copyRecursive(src, dest, flags) {
if (NEVER_COPIED.includes(src)) return;
const base = path.basename(src);
if (LITTER.has(base) || base.endsWith('.pyc')) return;
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!flags.dry) fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
copyRecursive(path.join(src, entry), path.join(dest, entry), flags);
}
} else {
if (fs.existsSync(dest) && !flags.force) {
skipped++;
console.log(` ${C.yellow}skip${C.reset} ${path.relative(process.cwd(), dest)} ${C.dim}(exists, use --force)${C.reset}`);
return;
}
if (!flags.dry) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
copied++;
console.log(` ${C.green}+${C.reset} ${path.relative(process.cwd(), dest)}`);
}
}
function installAreas(areaKeys, destRoot, flags) {
for (const key of areaKeys) {
const rel = AREAS[key];
if (!rel) {
console.error(` ${C.red}unknown area:${C.reset} ${key}`);
continue;
}
const src = path.join(ROOT, rel);
if (!fs.existsSync(src)) {
console.error(` ${C.red}missing in package:${C.reset} ${rel}`);
continue;
}
const dest = path.join(destRoot, rel);
copyRecursive(src, dest, flags);
}
}
// `new` scaffolds a product repo: the starter layout + the engine areas it needs.
// The template ships CLAUDE.local.md as *.template so this repo's own gitignore
// cannot swallow it; the suffix is dropped on the way out.
const TEMPLATE = path.join('templates', 'product-design');
/* The closing line. A dry run copies nothing, so it must not say "copied". */
function tally(flags, where = '') {
const to = where ? ` → ${where}` : '';
if (flags.dry) {
const into = where ? ` into ${where}` : '';
console.log(`\n${C.bold}Dry run:${C.reset} ${C.green}${copied} file(s) would be written${C.reset}${into}, ${C.yellow}${skipped} skipped${C.reset}. Nothing was changed.`);
} else {
console.log(`\n${C.bold}Done.${C.reset} ${C.green}${copied} copied${C.reset}, ${C.yellow}${skipped} skipped${C.reset}${to}`);
}
}
function cmdNew(destRoot, flags) {
const src = path.join(ROOT, TEMPLATE);
if (!fs.existsSync(src)) {
console.error(` ${C.red}missing in package:${C.reset} ${TEMPLATE}`);
process.exit(1);
}
console.log(`\n${C.bold}Scaffolding a new design project${C.reset} → ${destRoot}${flags.dry ? C.dim + ' (dry run)' + C.reset : ''}\n`);
for (const entry of fs.readdirSync(src)) {
const out = entry === 'CLAUDE.local.md.template' ? 'CLAUDE.local.md' : entry;
copyRecursive(path.join(src, entry), path.join(destRoot, out), flags);
}
// the engine, minus its own CLAUDE.md and rules: the product repo keeps the lean
// brief and the three project rules the template ships, not the engine's depth
installAreas(Object.keys(AREAS).filter(k => k !== 'claude' && k !== 'agentsmd' && k !== 'rules'), destRoot, flags);
console.log(`\n${C.bold}Next${C.reset}`);
console.log(` 1. Fill the placeholders in ${C.cyan}CLAUDE.md${C.reset} (product, user, stack, vocabulary).`);
console.log(` 2. Point ${C.cyan}design-tokens.json${C.reset} at your brand, then prove it:`);
console.log(` ${C.dim}python3 scripts/validate_contrast.py design-tokens.json${C.reset}`);
console.log(` 3. Put real screens in ${C.cyan}reference/${C.reset} and real imagery in ${C.cyan}public/images/${C.reset}.`);
console.log(` 4. Set the MCP env vars (FIGMA_API_KEY, ...) in your shell. Never commit a secret.`);
console.log(` 5. Run ${C.cyan}/gate${C.reset} in Claude Code before calling anything done.`);
}
/* `demo` answers the question an install cannot: what does this actually produce?
It copies the bundled examples - 23 component harnesses, the reference app,
two aesthetic demos, all rendering from one token theme - and opens the index.
Every page it opens is a page the gates measure, so a first-time reader is
looking at verified output, not a marketing shot. */
function cmdDemo(destRoot, flags) {
const src = path.join(ROOT, 'examples');
if (!fs.existsSync(src)) {
console.error(`${C.red}error:${C.reset} this install has no examples/ directory to show.`);
process.exit(1);
}
console.log(`\n${C.bold}Copying the rendered examples${C.reset} → ${destRoot}${flags.dry ? C.dim + ' (dry run)' + C.reset : ''}\n`);
copyRecursive(src, destRoot, flags);
const index = path.join(destRoot, 'index.html');
tally(flags);
console.log(`\n${C.bold}Start here${C.reset}`);
console.log(` ${C.cyan}${index}${C.reset}`);
console.log(` ${C.dim}the reference app, 23 component harnesses, light and dark, every page gate-verified${C.reset}`);
if (flags.dry || flags.noOpen) {
console.log(`\n${C.dim}Not opening a browser. Open the file above yourself.${C.reset}\n`);
return;
}
const opener = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
try {
require('child_process').spawn(opener, [index], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
console.log(`\n${C.dim}Opening it in your browser. Nothing was installed into your project - this is a copy you can delete.${C.reset}\n`);
} catch {
console.log(`\n${C.dim}Could not open a browser here. Open the file above yourself.${C.reset}\n`);
}
}
function cmdList() {
console.log(`\n${C.bold}Available areas${C.reset}\n`);
for (const key of Object.keys(AREAS)) {
console.log(` ${C.cyan}${key.padEnd(15)}${C.reset} ${DESC[key]}`);
}
console.log(`\n${C.dim}Install all:${C.reset} ${RUN} init`);
console.log(`${C.dim}Install some:${C.reset} ${RUN} add tokens components taste\n`);
}
function help() {
console.log(`
${C.bold}ux-ui-agent-skills${C.reset}: install the design-system skill kit
${C.bold}Usage${C.reset}
${RUN} ${C.cyan}init${C.reset} [dest] Install the full kit (default: current dir)
${RUN} ${C.cyan}new${C.reset} [dest] Scaffold a NEW product repo (starter layout + engine)
${RUN} ${C.cyan}add${C.reset} <area>... Install specific areas
${RUN} ${C.cyan}demo${C.reset} [dest] Copy the rendered examples and open them (default: ./ux-ui-demo)
${RUN} ${C.cyan}list${C.reset} List available areas
${RUN} ${C.cyan}library get${C.reset} <name> Fetch a design-system spec into this project (sha256-checked)
${RUN} ${C.cyan}library pull${C.reset} Fetch every spec, for offline use
${RUN} ${C.cyan}observe${C.reset} Did the agent follow the rules? (--last, --html, --help)
${RUN} ${C.cyan}version${C.reset} Print the installed version
${RUN} ${C.cyan}help${C.reset}
${C.bold}Flags${C.reset}
--agent <which> init only: which instruction surface to install
${C.cyan}claude${C.reset} (default) CLAUDE.md + .claude/ skills, rules, critic
${C.cyan}codex${C.reset} AGENTS.md (Codex, Cursor, Copilot, Aider, +more)
${C.cyan}both${C.reset} both files, held in step by a gate
${C.cyan}gemini${C.reset} GEMINI.md + .agents/skills/ (Gemini CLI, Antigravity)
--force, -f Overwrite existing files
--dry Show what would be copied, change nothing
--no-open demo only: copy the examples but do not launch a browser
${C.bold}Examples${C.reset}
${RUN} demo
${RUN} init
${RUN} init ./my-app
${RUN} init --agent codex ${C.dim}# for Codex / Cursor / Copilot${C.reset}
${RUN} init --agent gemini ${C.dim}# for Gemini CLI / Antigravity${C.reset}
${RUN} new ./my-product
${RUN} add tokens taste design-systems
${RUN} library get stripe linear-app
${RUN} add skills --force
`);
}
/* The next step depends on which surface was installed. Telling a Codex user to
open Claude Code and run /design-tokens names a file they do not have and a
mechanism their agent does not implement. */
function summary(destRoot, agent, flags) {
tally(flags, path.relative(process.cwd(), destRoot) || '.');
console.log(`${C.dim}Design systems arrive when used: ${RUN} library get <name> (the agent runs scripts/library.mjs itself).${C.reset}`);
if (agent === 'gemini') {
console.log(`${C.dim}Open the folder in Gemini CLI or Antigravity: GEMINI.md loads automatically. Gemini CLI reads .agents/skills/ only in a trusted folder: trust it when asked, then \`gemini skills list\` shows them.${C.reset}\n`);
} else if (agent === 'codex') {
console.log(`${C.dim}Open the folder in Codex, Cursor, Copilot or Aider: AGENTS.md is read automatically. Ask for a screen; the gates live in scripts/.${C.reset}\n`);
} else {
console.log(`${C.dim}Open the folder in Claude Code: CLAUDE.md loads automatically. Try /design-tokens, /design-code, /apply-aesthetic.${C.reset}\n`);
}
}
function main() {
const argv = process.argv.slice(2);
const cmd = argv[0];
/* observe has its own flags (--days, --session, --html), so it is handed the
raw arguments before the installer's parser can reject them. */
if (cmd === 'observe') {
const r = require('child_process').spawnSync(process.execPath,
[path.join(ROOT, 'scripts/observe.mjs'), ...argv.slice(1)], { stdio: 'inherit' });
process.exitCode = r.status === null ? 1 : r.status;
return;
}
/* library has its own flags and fetches into the directory it is run from,
not into the package, which under npx is a cache nobody looks in. */
if (cmd === 'library') {
const args = argv.slice(1);
if (!args.some(a => a === '--into' || a.startsWith('--into='))) args.push('--into', process.cwd());
const r = require('child_process').spawnSync(process.execPath,
[path.join(ROOT, 'scripts/library.mjs'), ...args], { stdio: 'inherit' });
process.exitCode = r.status === null ? 1 : r.status;
return;
}
const { flags, rest } = parseFlags(argv.slice(1));
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
if (cmd === 'list' || cmd === 'ls') return cmdList();
/* Read from the package that is actually running, never a literal. A typed
version drifts from package.json the first time one of them is bumped
alone, and the Homebrew formula's test asserts the two agree - which is the
point of having it: it catches a bottle built from the wrong tarball. */
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
console.log(require(path.join(ROOT, 'package.json')).version);
return;
}
if (cmd === 'init') {
const destRoot = path.resolve(rest[0] || process.cwd());
console.log(`\n${C.bold}Installing full kit${C.reset} → ${destRoot}${flags.dry ? C.dim + ' (dry run)' + C.reset : ''}\n`);
// --agent decides which instruction surface lands. Everything else - tokens,
// components, taste, and every gate - is the same either way, because none
// of it depends on which agent is reading.
const surfaces = { claude: ['claude'], codex: ['agentsmd'], both: ['claude', 'agentsmd'], gemini: [] };
const keep = surfaces[flags.agent];
// skills, rules, agents (the critic) and commands are Claude Code mechanisms.
// On the codex surface they would be dead files, so they stay out.
const claudeOnly = ['skills', 'rules', 'agents'];
installAreas(
Object.keys(AREAS).filter(k => {
if (k === 'claude' || k === 'agentsmd') return keep.includes(k);
if (flags.agent === 'codex' && claudeOnly.includes(k)) return false;
// The ported skills read .claude/rules/; the skills and critic themselves
// arrive ported, or not at all.
if (flags.agent === 'gemini' && (k === 'skills' || k === 'agents')) return false;
return true;
}),
destRoot, flags);
if (flags.agent === 'gemini') installGemini(destRoot, flags);
if (flags.agent === 'codex') {
console.log(`\n ${C.dim}AGENTS.md installed. Codex, Cursor, Copilot, Jules and ~25 more read it.${C.reset}`);
console.log(` ${C.dim}The .claude/ skills were not installed: they are Claude Code only.${C.reset}`);
}
return summary(destRoot, flags.agent, flags);
}
if (cmd === 'new') {
const destRoot = path.resolve(rest[0] || process.cwd());
if (!flags.dry) fs.mkdirSync(destRoot, { recursive: true });
cmdNew(destRoot, flags);
return summary(destRoot, undefined, flags);
}
if (cmd === 'demo') {
const destRoot = path.resolve(rest[0] || path.join(process.cwd(), 'ux-ui-demo'));
if (!flags.dry) fs.mkdirSync(destRoot, { recursive: true });
return cmdDemo(destRoot, flags);
}
if (cmd === 'add') {
if (!rest.length) {
console.error(`${C.red}error:${C.reset} specify at least one area. See: ${RUN} list`);
process.exit(1);
}
const destRoot = process.cwd();
console.log(`\n${C.bold}Installing:${C.reset} ${rest.join(', ')}${flags.dry ? C.dim + ' (dry run)' + C.reset : ''}\n`);
installAreas(rest, destRoot, flags);
return summary(destRoot, undefined, flags);
}
console.error(`${C.red}unknown command:${C.reset} ${cmd}\n`);
help();
process.exit(1);
}
main();