basuicn 0.1.4 → 0.1.5

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 (2) hide show
  1. package/package.json +16 -3
  2. package/scripts/ui-cli.ts +327 -121
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "basuicn",
3
3
  "private": false,
4
- "version": "0.1.4",
4
+ "version": "0.1.5",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "basuicn": "./dist/ui-cli.cjs"
@@ -23,7 +23,9 @@
23
23
  "test:coverage": "vitest run --coverage",
24
24
  "registry:build": "npx -y tsx scripts/build-registry.ts",
25
25
  "theme:sync": "npx -y tsx scripts/generate-theme-css.ts",
26
- "ui:add": "npx -y tsx scripts/ui-cli.ts add"
26
+ "ui:add": "npx -y tsx scripts/ui-cli.ts add",
27
+ "storybook": "storybook dev -p 6006",
28
+ "build-storybook": "storybook build"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@babel/core": "^7.29.0",
@@ -81,7 +83,18 @@
81
83
  "unified": "^11.0.5",
82
84
  "vite": "^8.0.1",
83
85
  "vitest": "^4.1.2",
84
- "zod": "^4.3.6"
86
+ "zod": "^4.3.6",
87
+ "storybook": "^10.3.4",
88
+ "@storybook/react-vite": "^10.3.4",
89
+ "@chromatic-com/storybook": "^5.1.1",
90
+ "@storybook/addon-vitest": "^10.3.4",
91
+ "@storybook/addon-a11y": "^10.3.4",
92
+ "@storybook/addon-docs": "^10.3.4",
93
+ "@storybook/addon-onboarding": "^10.3.4",
94
+ "eslint-plugin-storybook": "^10.3.4",
95
+ "playwright": "^1.59.1",
96
+ "@vitest/browser-playwright": "^4.1.2",
97
+ "@vitest/coverage-v8": "^4.1.2"
85
98
  },
86
99
  "dependencies": {
87
100
  "keen-slider": "^6.8.6"
package/scripts/ui-cli.ts CHANGED
@@ -2,17 +2,56 @@
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execSync } from 'child_process';
5
+ import readline from 'readline';
5
6
 
7
+ // ─── Constants ────────────────────────────────────────────────────────────────
8
+
9
+ const VERSION = '0.1.4';
6
10
  const REGISTRY_LOCAL = './registry.json';
7
11
  const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/huy14032003/ui-component/main/registry.json';
8
12
 
9
- const log = (msg: string) => console.log(`[basuicn] ${msg}`);
10
- const warn = (msg: string) => console.warn(`[basuicn] WARN: ${msg}`);
11
- const error = (msg: string) => console.error(`[basuicn] ERROR: ${msg}`);
13
+ // ─── Colors (ANSI) ───────────────────────────────────────────────────────────
14
+
15
+ const c = {
16
+ reset: '\x1b[0m',
17
+ bold: '\x1b[1m',
18
+ dim: '\x1b[2m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ red: '\x1b[31m',
22
+ cyan: '\x1b[36m',
23
+ magenta: '\x1b[35m',
24
+ blue: '\x1b[34m',
25
+ gray: '\x1b[90m',
26
+ };
27
+
28
+ const log = (msg: string) => console.log(`${c.cyan}▸${c.reset} ${msg}`);
29
+ const ok = (msg: string) => console.log(`${c.green}✔${c.reset} ${msg}`);
30
+ const warn = (msg: string) => console.warn(`${c.yellow}⚠${c.reset} ${msg}`);
31
+ const error = (msg: string) => console.error(`${c.red}✖${c.reset} ${msg}`);
12
32
 
13
33
  const getTargetProjectDir = () => process.cwd();
14
34
 
15
- // ─── Registry ──────────────────────���─────────────────────────────���────────────
35
+ // ─── Interactive prompt ──────────────────────────────────────────────────────
36
+
37
+ const ask = (question: string): Promise<string> => {
38
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
39
+ return new Promise((resolve) => {
40
+ rl.question(`${c.cyan}?${c.reset} ${question} `, (answer) => {
41
+ rl.close();
42
+ resolve(answer.trim());
43
+ });
44
+ });
45
+ };
46
+
47
+ const confirm = async (question: string, defaultYes = true): Promise<boolean> => {
48
+ const hint = defaultYes ? 'Y/n' : 'y/N';
49
+ const answer = await ask(`${question} ${c.dim}(${hint})${c.reset}`);
50
+ if (!answer) return defaultYes;
51
+ return answer.toLowerCase().startsWith('y');
52
+ };
53
+
54
+ // ─── Registry ─────────────────────────────────────────────────────────────────
16
55
 
17
56
  interface RegistryFile { path: string; content: string }
18
57
  interface RegistryComponent {
@@ -80,7 +119,7 @@ const installNpmPackages = (packages: string[], cwd: string, dev = false) => {
80
119
 
81
120
  if (toInstall.length === 0) return;
82
121
 
83
- log(`Installing: ${toInstall.join(', ')}...`);
122
+ log(`Installing: ${c.bold}${toInstall.join(', ')}${c.reset}...`);
84
123
  const flag = dev ? '--save-dev' : '--save';
85
124
  try {
86
125
  execSync(`npm install ${toInstall.join(' ')} ${flag}`, { stdio: 'inherit', cwd });
@@ -89,9 +128,8 @@ const installNpmPackages = (packages: string[], cwd: string, dev = false) => {
89
128
  }
90
129
  };
91
130
 
92
- // ─── Packages ───────────────────────��─────────────────────────────��───────────
131
+ // ─── Packages ─────────────────────────────────────────────────────────────────
93
132
 
94
- /** Build/dev tooling installed as devDependencies */
95
133
  const VITE_DEV_PACKAGES = [
96
134
  'tailwindcss',
97
135
  '@tailwindcss/vite',
@@ -99,10 +137,6 @@ const VITE_DEV_PACKAGES = [
99
137
  '@types/node',
100
138
  ];
101
139
 
102
- /**
103
- * Runtime packages every project using these components needs.
104
- * Installed as regular dependencies.
105
- */
106
140
  const RUNTIME_PACKAGES = [
107
141
  '@base-ui/react',
108
142
  'tailwind-variants',
@@ -112,7 +146,7 @@ const RUNTIME_PACKAGES = [
112
146
  'lucide-react',
113
147
  ];
114
148
 
115
- // ─── Vite config ────────────────────────────���──────────────────────────��──────
149
+ // ─── Vite config ──────────────────────────────────────────────────────────────
116
150
 
117
151
  const VITE_CONFIG_TEMPLATE = `import { defineConfig } from 'vite';
118
152
  import tailwindcss from '@tailwindcss/vite';
@@ -151,7 +185,7 @@ const setupViteConfig = (cwd: string) => {
151
185
 
152
186
  if (!fs.existsSync(configTs) && !fs.existsSync(configJs)) {
153
187
  fs.writeFileSync(configTs, VITE_CONFIG_TEMPLATE);
154
- log('Created vite.config.ts.');
188
+ ok('Created vite.config.ts.');
155
189
  return;
156
190
  }
157
191
 
@@ -170,7 +204,7 @@ const setupViteConfig = (cwd: string) => {
170
204
  const hasAlias = content.includes('alias:') || content.includes("'@'") || content.includes('"@"');
171
205
 
172
206
  if (missingImports.length === 0 && missingPlugins.length === 0 && hasAlias) {
173
- log('vite.config already configured — skipping.');
207
+ ok('vite.config already configured — skipping.');
174
208
  return;
175
209
  }
176
210
 
@@ -229,10 +263,10 @@ const setupViteConfig = (cwd: string) => {
229
263
  }
230
264
 
231
265
  fs.writeFileSync(existingPath, content);
232
- log(`Updated ${path.basename(existingPath)} with Tailwind + path aliases.`);
266
+ ok(`Updated ${path.basename(existingPath)} with Tailwind + path aliases.`);
233
267
  };
234
268
 
235
- // ─── tsconfig ────────────────────────��────────────────────────────────���───────
269
+ // ─── tsconfig ─────────────────────────────────────────────────────────────────
236
270
 
237
271
  const setupTsConfig = (cwd: string) => {
238
272
  const candidates = ['tsconfig.app.json', 'tsconfig.json'];
@@ -244,7 +278,7 @@ const setupTsConfig = (cwd: string) => {
244
278
  const raw = fs.readFileSync(configPath, 'utf-8');
245
279
 
246
280
  if (raw.includes('"@/*"') || raw.includes("'@/*'")) {
247
- log(`${candidate} already has path aliases — skipping.`);
281
+ ok(`${candidate} already has path aliases — skipping.`);
248
282
  return;
249
283
  }
250
284
 
@@ -257,7 +291,7 @@ const setupTsConfig = (cwd: string) => {
257
291
  parsed.compilerOptions.baseUrl = '.';
258
292
  parsed.compilerOptions.paths = TSCONFIG_PATHS;
259
293
  fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2));
260
- log(`Added path aliases to ${candidate}.`);
294
+ ok(`Added path aliases to ${candidate}.`);
261
295
  } catch (err) {
262
296
  warn(`Could not auto-patch ${candidate}: ${err instanceof Error ? err.message : err}`);
263
297
  warn('Add these to compilerOptions manually:');
@@ -274,15 +308,11 @@ const setupTsConfig = (cwd: string) => {
274
308
 
275
309
  const newConfig = { compilerOptions: { baseUrl: '.', paths: TSCONFIG_PATHS } };
276
310
  fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify(newConfig, null, 2));
277
- log('Created tsconfig.json with path aliases.');
311
+ ok('Created tsconfig.json with path aliases.');
278
312
  };
279
313
 
280
- // ─── Core files ────────────────────────────���─────────────────────────────��────
314
+ // ─── Core files ───────────────────────────────────────────────────────────────
281
315
 
282
- /**
283
- * Copy core files (cn.ts, index.css, ThemeProvider, themes.ts) from registry.
284
- * Pass force=true on `init` to always overwrite — keeps core up to date.
285
- */
286
316
  const ensureCore = (
287
317
  registry: { core?: { dependencies: string[]; files: RegistryFile[] } },
288
318
  cwd: string,
@@ -300,21 +330,17 @@ const ensureCore = (
300
330
  if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
301
331
 
302
332
  if (fs.existsSync(targetPath) && !options.force) {
303
- log(`Core file exists (skipping): ${file.path}`);
333
+ log(`Core file exists (skipping): ${c.dim}${file.path}${c.reset}`);
304
334
  continue;
305
335
  }
306
336
 
307
337
  fs.writeFileSync(targetPath, file.content);
308
- log(`${fs.existsSync(targetPath) ? 'Updated' : 'Created'} core file: ${file.path}`);
338
+ ok(`${fs.existsSync(targetPath) ? 'Updated' : 'Created'} core file: ${file.path}`);
309
339
  }
310
340
  };
311
341
 
312
342
  // ─── main.tsx patching ────────────────────────────────────────────────────────
313
343
 
314
- /**
315
- * Components that need additional setup in the main entry file.
316
- * Key = component name in registry, value = import + JSX to inject.
317
- */
318
344
  const MAIN_PATCH_COMPONENTS: Record<string, { import: string; jsx: string }> = {
319
345
  toast: {
320
346
  import: "import { Toaster } from '@/components/ui/toast/Toaster';",
@@ -333,7 +359,6 @@ const findMainFile = (cwd: string): string | null => {
333
359
  };
334
360
 
335
361
  const insertImport = (content: string, importLine: string): string => {
336
- // Don't add duplicate
337
362
  if (content.includes(importLine)) return content;
338
363
  const allImports = [...content.matchAll(/^import\s.+$/gm)];
339
364
  if (allImports.length > 0) {
@@ -344,14 +369,6 @@ const insertImport = (content: string, importLine: string): string => {
344
369
  return importLine + '\n' + content;
345
370
  };
346
371
 
347
- /**
348
- * Patches the main entry file to:
349
- * 1. Import src/styles/index.css (theme variables + Tailwind)
350
- * 2. Import ThemeProvider
351
- * 3. Wrap <App /> with <ThemeProvider>
352
- *
353
- * Safe to call multiple times — skips sections that are already set up.
354
- */
355
372
  const patchMainTsx = (cwd: string) => {
356
373
  const mainPath = findMainFile(cwd);
357
374
  if (!mainPath) {
@@ -362,11 +379,9 @@ const patchMainTsx = (cwd: string) => {
362
379
  let content = fs.readFileSync(mainPath, 'utf-8');
363
380
  let changed = false;
364
381
 
365
- // 1. Ensure styles/index.css is imported
366
382
  const cssImportLine = "import './styles/index.css';";
367
383
  const hasCssImport = content.includes('styles/index.css') || content.includes('index.css');
368
384
  if (!hasCssImport) {
369
- // Insert at top before other imports
370
385
  const firstImport = content.match(/^import\s/m);
371
386
  if (firstImport?.index !== undefined) {
372
387
  content = content.slice(0, firstImport.index) + cssImportLine + '\n' + content.slice(firstImport.index);
@@ -375,12 +390,10 @@ const patchMainTsx = (cwd: string) => {
375
390
  }
376
391
  changed = true;
377
392
  } else if (!content.includes('styles/index.css')) {
378
- // Has some CSS import but not our theme CSS — add it alongside
379
393
  content = insertImport(content, cssImportLine);
380
394
  changed = true;
381
395
  }
382
396
 
383
- // 2. ThemeProvider
384
397
  if (!content.includes('ThemeProvider')) {
385
398
  content = insertImport(content, "import { ThemeProvider } from '@/lib/theme/ThemeProvider';");
386
399
 
@@ -395,16 +408,12 @@ const patchMainTsx = (cwd: string) => {
395
408
 
396
409
  if (changed) {
397
410
  fs.writeFileSync(mainPath, content);
398
- log(`Patched ${path.relative(cwd, mainPath)}.`);
411
+ ok(`Patched ${path.relative(cwd, mainPath)}.`);
399
412
  } else {
400
- log(`${path.relative(cwd, mainPath)} already configured — skipping.`);
413
+ ok(`${path.relative(cwd, mainPath)} already configured — skipping.`);
401
414
  }
402
415
  };
403
416
 
404
- /**
405
- * Injects a component's bootstrap JSX (e.g. <Toaster />) into the main entry file.
406
- * Places it inside <ThemeProvider> after <App />, falls back to right after <App />.
407
- */
408
417
  const patchMainTsxComponent = (cwd: string, componentName: string) => {
409
418
  const patch = MAIN_PATCH_COMPONENTS[componentName];
410
419
  if (!patch) return;
@@ -413,7 +422,6 @@ const patchMainTsxComponent = (cwd: string, componentName: string) => {
413
422
  if (!mainPath) return;
414
423
 
415
424
  let content = fs.readFileSync(mainPath, 'utf-8');
416
- // Check by component tag name (e.g. "Toaster")
417
425
  const tagName = patch.jsx.match(/<(\w+)/)?.[1];
418
426
  if (tagName && content.includes(`<${tagName}`)) return;
419
427
 
@@ -431,10 +439,10 @@ const patchMainTsxComponent = (cwd: string, componentName: string) => {
431
439
  if (fallback !== content) fs.writeFileSync(mainPath, fallback);
432
440
  }
433
441
 
434
- log(`Added <${tagName}> to ${path.relative(cwd, mainPath)}.`);
442
+ ok(`Added <${tagName}> to ${path.relative(cwd, mainPath)}.`);
435
443
  };
436
444
 
437
- // ─── Component add/remove ──────────────────────────��──────────────────────────
445
+ // ─── Component add/remove ─────────────────────────────────────────────────────
438
446
 
439
447
  const addComponent = (
440
448
  name: string,
@@ -448,11 +456,11 @@ const addComponent = (
448
456
 
449
457
  const component = registry.components[name];
450
458
  if (!component) {
451
- error(`Component "${name}" not found. Run 'list' to see available components.`);
459
+ error(`Component "${name}" not found. Run '${c.cyan}basuicn list${c.reset}' to see available components.`);
452
460
  return;
453
461
  }
454
462
 
455
- log(`Adding: ${name}...`);
463
+ log(`Adding: ${c.bold}${name}${c.reset}...`);
456
464
 
457
465
  ensureCore(registry as Parameters<typeof ensureCore>[0], cwd);
458
466
  installNpmPackages(component.dependencies, cwd);
@@ -472,12 +480,12 @@ const addComponent = (
472
480
  if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
473
481
 
474
482
  if (fs.existsSync(targetPath) && !options.force) {
475
- warn(`Skipped (exists): ${file.path} — use --force to overwrite`);
483
+ warn(`Skipped (exists): ${file.path} — use ${c.cyan}--force${c.reset} to overwrite`);
476
484
  continue;
477
485
  }
478
486
 
479
487
  fs.writeFileSync(targetPath, file.content);
480
- log(`Created: ${file.path}`);
488
+ ok(`Created: ${file.path}`);
481
489
  }
482
490
  };
483
491
 
@@ -492,13 +500,13 @@ const removeComponent = (
492
500
  return;
493
501
  }
494
502
 
495
- log(`Removing: ${name}...`);
503
+ log(`Removing: ${c.bold}${name}${c.reset}...`);
496
504
 
497
505
  for (const file of component.files) {
498
506
  const targetPath = path.join(cwd, file.path);
499
507
  if (fs.existsSync(targetPath)) {
500
508
  fs.unlinkSync(targetPath);
501
- log(`Deleted: ${file.path}`);
509
+ ok(`Deleted: ${file.path}`);
502
510
  }
503
511
  }
504
512
 
@@ -507,7 +515,7 @@ const removeComponent = (
507
515
  try {
508
516
  if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) {
509
517
  fs.rmdirSync(targetDir);
510
- log(`Removed empty dir: ${path.relative(cwd, targetDir)}`);
518
+ ok(`Removed empty dir: ${path.relative(cwd, targetDir)}`);
511
519
  }
512
520
  } catch (err) {
513
521
  warn(`Could not remove directory: ${err instanceof Error ? err.message : err}`);
@@ -515,16 +523,169 @@ const removeComponent = (
515
523
  }
516
524
  };
517
525
 
526
+ // ─── Help texts ───────────────────────────────────────────────────────────────
527
+
528
+ const HELP_MAIN = `
529
+ ${c.bold}${c.cyan}basuicn${c.reset} ${c.dim}v${VERSION}${c.reset} — Modern React UI Component CLI
530
+
531
+ ${c.bold}USAGE${c.reset}
532
+ ${c.cyan}npx basuicn${c.reset} ${c.green}<command>${c.reset} ${c.dim}[options]${c.reset}
533
+
534
+ ${c.bold}COMMANDS${c.reset}
535
+ ${c.green}init${c.reset} Initialize project: install deps, copy core files, patch entry
536
+ ${c.green}add${c.reset} ${c.dim}<name...>${c.reset} Add component(s) to your project
537
+ ${c.green}update${c.reset} ${c.dim}<name...>${c.reset} Update component(s) to latest registry version
538
+ ${c.green}diff${c.reset} ${c.dim}<name...>${c.reset} Show diff between local and registry version
539
+ ${c.green}remove${c.reset} ${c.dim}<name...>${c.reset} Remove component(s) from your project
540
+ ${c.green}list${c.reset} List all available components
541
+ ${c.green}doctor${c.reset} Check project health and configuration
542
+
543
+ ${c.bold}OPTIONS${c.reset}
544
+ ${c.cyan}--force${c.reset} Overwrite existing files when adding/updating
545
+ ${c.cyan}--local${c.reset} Use local registry.json instead of remote
546
+ ${c.cyan}--help, -h${c.reset} Show help (use with a command for detailed help)
547
+ ${c.cyan}--version, -v${c.reset} Show version
548
+
549
+ ${c.bold}QUICK START${c.reset}
550
+ ${c.dim}$${c.reset} npx basuicn init
551
+ ${c.dim}$${c.reset} npx basuicn add button input card
552
+ ${c.dim}$${c.reset} npx basuicn add toast
553
+
554
+ ${c.bold}EXAMPLES${c.reset}
555
+ ${c.dim}$${c.reset} npx basuicn add dialog --force ${c.dim}# Overwrite existing dialog${c.reset}
556
+ ${c.dim}$${c.reset} npx basuicn diff button ${c.dim}# See what changed since last update${c.reset}
557
+ ${c.dim}$${c.reset} npx basuicn doctor ${c.dim}# Diagnose missing deps/config${c.reset}
558
+
559
+ ${c.dim}Documentation: https://github.com/huy14032003/ui-component${c.reset}
560
+ `;
561
+
562
+ const HELP_COMMANDS: Record<string, string> = {
563
+ init: `
564
+ ${c.bold}basuicn init${c.reset}
565
+
566
+ Initialize your project for basuicn components.
567
+
568
+ ${c.bold}What it does:${c.reset}
569
+ 1. Installs runtime dependencies (@base-ui/react, tailwind-variants, etc.)
570
+ 2. Sets up vite.config.ts with Tailwind CSS + path aliases
571
+ 3. Patches tsconfig.json with path aliases (@/*, @lib/*, etc.)
572
+ 4. Copies core files (cn.ts, themes.ts, ThemeProvider.tsx, index.css)
573
+ 5. Wraps your <App /> with <ThemeProvider> in the main entry
574
+
575
+ ${c.bold}Usage:${c.reset}
576
+ ${c.dim}$${c.reset} npx basuicn init
577
+ ${c.dim}$${c.reset} npx basuicn init --local ${c.dim}# Use local registry${c.reset}
578
+ `,
579
+ add: `
580
+ ${c.bold}basuicn add${c.reset} ${c.dim}<name...>${c.reset}
581
+
582
+ Add one or more components to your project.
583
+
584
+ ${c.bold}Options:${c.reset}
585
+ ${c.cyan}--force${c.reset} Overwrite existing component files
586
+
587
+ ${c.bold}Features:${c.reset}
588
+ • Auto-runs init if project hasn't been set up
589
+ • Resolves internal dependencies (e.g., dialog depends on button)
590
+ • Installs required npm packages automatically
591
+ • Patches main entry for components that need it (e.g., toast)
592
+
593
+ ${c.bold}Usage:${c.reset}
594
+ ${c.dim}$${c.reset} npx basuicn add button
595
+ ${c.dim}$${c.reset} npx basuicn add button input card dialog
596
+ ${c.dim}$${c.reset} npx basuicn add toast --force
597
+
598
+ ${c.bold}Interactive:${c.reset}
599
+ ${c.dim}$${c.reset} npx basuicn add ${c.dim}# Prompts to select components${c.reset}
600
+ `,
601
+ update: `
602
+ ${c.bold}basuicn update${c.reset} ${c.dim}<name...>${c.reset}
603
+
604
+ Update component(s) to the latest registry version.
605
+ Equivalent to ${c.cyan}add --force${c.reset}.
606
+
607
+ ${c.bold}Usage:${c.reset}
608
+ ${c.dim}$${c.reset} npx basuicn update button
609
+ ${c.dim}$${c.reset} npx basuicn update button card dialog
610
+ `,
611
+ remove: `
612
+ ${c.bold}basuicn remove${c.reset} ${c.dim}<name...>${c.reset}
613
+
614
+ Remove component(s) from your project.
615
+ Deletes component files and cleans up empty directories.
616
+
617
+ ${c.bold}Usage:${c.reset}
618
+ ${c.dim}$${c.reset} npx basuicn remove button
619
+ ${c.dim}$${c.reset} npx basuicn remove dialog drawer sheet
620
+ `,
621
+ diff: `
622
+ ${c.bold}basuicn diff${c.reset} ${c.dim}<name...>${c.reset}
623
+
624
+ Show differences between your local component files and the registry version.
625
+ Useful to see what has changed before running update.
626
+
627
+ ${c.bold}Usage:${c.reset}
628
+ ${c.dim}$${c.reset} npx basuicn diff button
629
+ ${c.dim}$${c.reset} npx basuicn diff button card
630
+ `,
631
+ list: `
632
+ ${c.bold}basuicn list${c.reset}
633
+
634
+ Show all available components in the registry.
635
+ Displays internal dependencies for each component.
636
+
637
+ ${c.bold}Usage:${c.reset}
638
+ ${c.dim}$${c.reset} npx basuicn list
639
+ `,
640
+ doctor: `
641
+ ${c.bold}basuicn doctor${c.reset}
642
+
643
+ Run a health check on your project configuration.
644
+
645
+ ${c.bold}Checks:${c.reset}
646
+ • Core files exist (cn.ts, themes.ts, ThemeProvider.tsx, index.css)
647
+ • ThemeProvider + CSS import in main entry
648
+ • Runtime packages installed
649
+ • Dev packages installed
650
+ • Tailwind CSS configured
651
+ • TypeScript path aliases
652
+ • Vite config present
653
+
654
+ ${c.bold}Usage:${c.reset}
655
+ ${c.dim}$${c.reset} npx basuicn doctor
656
+ `,
657
+ };
658
+
518
659
  // ─── Commands ─────────────────────────────────────────────────────────────────
519
660
 
520
661
  const main = async () => {
521
662
  const args = process.argv.slice(2);
663
+
664
+ // Version flag
665
+ if (args.includes('--version') || args.includes('-v')) {
666
+ console.log(`basuicn v${VERSION}`);
667
+ return;
668
+ }
669
+
522
670
  const isLocal = args.includes('--local');
523
671
  const isForce = args.includes('--force');
524
- const filteredArgs = args.filter((a) => !a.startsWith('--'));
672
+ const isHelp = args.includes('--help') || args.includes('-h');
673
+ const filteredArgs = args.filter((a) => !a.startsWith('--') && a !== '-h' && a !== '-v');
525
674
  const command = filteredArgs[0];
526
675
  const componentNames = filteredArgs.slice(1);
527
676
 
677
+ // Help for specific command
678
+ if (isHelp && command && HELP_COMMANDS[command]) {
679
+ console.log(HELP_COMMANDS[command]);
680
+ return;
681
+ }
682
+
683
+ // General help
684
+ if (isHelp || !command) {
685
+ console.log(HELP_MAIN);
686
+ return;
687
+ }
688
+
528
689
  const cwd = getTargetProjectDir();
529
690
  const registry = await getRegistry(isLocal);
530
691
 
@@ -535,17 +696,43 @@ const main = async () => {
535
696
  setupViteConfig(cwd);
536
697
  setupTsConfig(cwd);
537
698
  installNpmPackages(RUNTIME_PACKAGES, cwd);
538
- // force=true so init always refreshes core files to latest version
539
699
  ensureCore(registry, cwd, { force: true });
540
700
  patchMainTsx(cwd);
541
- log('Initialization complete.');
701
+ console.log('');
702
+ ok(`${c.bold}Initialization complete!${c.reset} Run ${c.cyan}npx basuicn add <component>${c.reset} to get started.`);
542
703
  break;
543
704
  }
544
705
 
545
706
  case 'add': {
546
- if (componentNames.length === 0) {
547
- error('Usage: npx basuicn add <component-name> [--force]');
548
- return;
707
+ let names = componentNames;
708
+
709
+ // Interactive mode: no component names provided
710
+ if (names.length === 0) {
711
+ const all = Object.keys(registry.components).sort();
712
+ console.log(`\n${c.bold}Available components (${all.length}):${c.reset}`);
713
+
714
+ // Group by category
715
+ const categories: Record<string, string[]> = {};
716
+ for (const name of all) {
717
+ const prefix = name.includes('-') ? name.split('-')[0] : 'general';
718
+ if (!categories[prefix]) categories[prefix] = [];
719
+ categories[prefix].push(name);
720
+ }
721
+
722
+ // Print in columns
723
+ const cols = 4;
724
+ for (let i = 0; i < all.length; i += cols) {
725
+ const row = all.slice(i, i + cols).map(n => n.padEnd(20)).join('');
726
+ console.log(` ${c.dim}${row}${c.reset}`);
727
+ }
728
+
729
+ console.log('');
730
+ const answer = await ask(`Which components to add? ${c.dim}(space-separated, or "all")${c.reset}`);
731
+ if (!answer) {
732
+ log('No components selected.');
733
+ return;
734
+ }
735
+ names = answer === 'all' ? all : answer.split(/[\s,]+/).filter(Boolean);
549
736
  }
550
737
 
551
738
  // Auto-init if project hasn't been initialized yet
@@ -557,108 +744,147 @@ const main = async () => {
557
744
  installNpmPackages(RUNTIME_PACKAGES, cwd);
558
745
  ensureCore(registry, cwd, { force: true });
559
746
  patchMainTsx(cwd);
747
+ console.log('');
560
748
  }
561
749
 
562
- for (const name of componentNames) {
750
+ for (const name of names) {
563
751
  addComponent(name, registry, cwd, { force: isForce });
564
752
  patchMainTsxComponent(cwd, name);
565
753
  }
566
- log('Done!');
754
+ console.log('');
755
+ ok(`${c.bold}Done!${c.reset} Added ${names.length} component(s).`);
567
756
  break;
568
757
  }
569
758
 
570
759
  case 'update': {
571
760
  if (componentNames.length === 0) {
572
- error('Usage: npx basuicn update <component-name> [...]');
761
+ error(`Usage: ${c.cyan}npx basuicn update <component-name> [...]${c.reset}`);
762
+ console.log(` Run ${c.cyan}npx basuicn update --help${c.reset} for details.`);
573
763
  return;
574
764
  }
575
765
  for (const name of componentNames) {
576
- log(`Updating: ${name}...`);
766
+ log(`Updating: ${c.bold}${name}${c.reset}...`);
577
767
  addComponent(name, registry, cwd, { force: true });
578
768
  }
579
- log('Update complete.');
769
+ console.log('');
770
+ ok(`${c.bold}Update complete.${c.reset}`);
580
771
  break;
581
772
  }
582
773
 
583
774
  case 'remove': {
584
775
  if (componentNames.length === 0) {
585
- error('Usage: npx basuicn remove <component-name>');
776
+ error(`Usage: ${c.cyan}npx basuicn remove <component-name>${c.reset}`);
586
777
  return;
587
778
  }
779
+
780
+ if (!isForce) {
781
+ const yes = await confirm(`Remove ${componentNames.join(', ')}?`);
782
+ if (!yes) {
783
+ log('Cancelled.');
784
+ return;
785
+ }
786
+ }
787
+
588
788
  for (const name of componentNames) {
589
789
  removeComponent(name, registry, cwd);
590
790
  }
591
- log('Done!');
791
+ console.log('');
792
+ ok(`${c.bold}Done!${c.reset}`);
592
793
  break;
593
794
  }
594
795
 
595
796
  case 'list': {
596
797
  const components = Object.keys(registry.components).sort();
597
- log(`Available components (${components.length}):`);
798
+ console.log(`\n${c.bold}Available components (${components.length}):${c.reset}\n`);
799
+
800
+ const installed: string[] = [];
801
+ const available: string[] = [];
802
+
598
803
  for (const k of components) {
599
804
  const comp = registry.components[k];
600
- const deps = comp.internalDependencies?.filter(Boolean);
601
- const depStr = deps?.length ? ` (requires: ${deps.join(', ')})` : '';
602
- console.log(` - ${k}${depStr}`);
805
+ const firstFile = comp.files[0];
806
+ const isInstalled = firstFile && fs.existsSync(path.join(cwd, firstFile.path));
807
+ if (isInstalled) installed.push(k);
808
+ else available.push(k);
603
809
  }
810
+
811
+ if (installed.length > 0) {
812
+ console.log(` ${c.green}Installed (${installed.length}):${c.reset}`);
813
+ for (const k of installed) {
814
+ const deps = registry.components[k].internalDependencies?.filter(Boolean);
815
+ const depStr = deps?.length ? ` ${c.dim}→ ${deps.join(', ')}${c.reset}` : '';
816
+ console.log(` ${c.green}●${c.reset} ${k}${depStr}`);
817
+ }
818
+ console.log('');
819
+ }
820
+
821
+ if (available.length > 0) {
822
+ console.log(` ${c.dim}Available (${available.length}):${c.reset}`);
823
+ for (const k of available) {
824
+ const deps = registry.components[k].internalDependencies?.filter(Boolean);
825
+ const depStr = deps?.length ? ` ${c.dim}→ ${deps.join(', ')}${c.reset}` : '';
826
+ console.log(` ${c.dim}○${c.reset} ${k}${depStr}`);
827
+ }
828
+ }
829
+ console.log('');
604
830
  break;
605
831
  }
606
832
 
607
833
  case 'diff': {
608
834
  if (componentNames.length === 0) {
609
- error('Usage: npx basuicn diff <component-name>');
835
+ error(`Usage: ${c.cyan}npx basuicn diff <component-name>${c.reset}`);
610
836
  return;
611
837
  }
612
838
  for (const name of componentNames) {
613
839
  const component = registry.components[name];
614
840
  if (!component) {
615
- error(`Component "${name}" not found. Run 'list' to see available components.`);
841
+ error(`Component "${name}" not found.`);
616
842
  continue;
617
843
  }
618
844
  let hasDiff = false;
619
- console.log(`\n[diff] ${name}`);
845
+ console.log(`\n${c.bold}[diff] ${name}${c.reset}`);
620
846
  for (const file of component.files) {
621
847
  const targetPath = path.join(cwd, file.path);
622
848
  if (!fs.existsSync(targetPath)) {
623
- console.log(` + [new file] ${file.path}`);
849
+ console.log(` ${c.green}+ [new file]${c.reset} ${file.path}`);
624
850
  hasDiff = true;
625
851
  continue;
626
852
  }
627
853
  const localContent = fs.readFileSync(targetPath, 'utf-8');
628
854
  if (localContent === file.content) continue;
629
855
  hasDiff = true;
630
- console.log(`\n ~ ${file.path}`);
856
+ console.log(`\n ${c.yellow}~${c.reset} ${file.path}`);
631
857
  const localLines = localContent.split('\n');
632
858
  const remoteLines = file.content.split('\n');
633
859
  const maxLen = Math.max(localLines.length, remoteLines.length);
634
860
  let shownLines = 0;
635
861
  for (let i = 0; i < maxLen; i++) {
636
862
  if (localLines[i] !== remoteLines[i]) {
637
- if (localLines[i] !== undefined) console.log(` - ${localLines[i]}`);
638
- if (remoteLines[i] !== undefined) console.log(` + ${remoteLines[i]}`);
863
+ if (localLines[i] !== undefined) console.log(` ${c.red}- ${localLines[i]}${c.reset}`);
864
+ if (remoteLines[i] !== undefined) console.log(` ${c.green}+ ${remoteLines[i]}${c.reset}`);
639
865
  shownLines++;
640
866
  if (shownLines >= 20) {
641
867
  const remaining = maxLen - i - 1;
642
- if (remaining > 0) console.log(` ... and ${remaining} more lines`);
868
+ if (remaining > 0) console.log(` ${c.dim}... and ${remaining} more lines${c.reset}`);
643
869
  break;
644
870
  }
645
871
  }
646
872
  }
647
873
  }
648
- if (!hasDiff) log(`${name}: already up to date.`);
874
+ if (!hasDiff) ok(`${name}: already up to date.`);
649
875
  }
650
876
  break;
651
877
  }
652
878
 
653
879
  case 'doctor': {
654
- log('Running project health check...\n');
880
+ console.log(`\n${c.bold}Project Health Check${c.reset}\n`);
655
881
  let issues = 0;
656
- const check = (ok: boolean, msg: string, fix?: string) => {
657
- console.log(`${ok ? ' ✓' : ' ✗'} ${msg}`);
658
- if (!ok) { if (fix) console.log(` → ${fix}`); issues++; }
882
+ const check = (passed: boolean, msg: string, fix?: string) => {
883
+ console.log(` ${passed ? `${c.green}✔${c.reset}` : `${c.red}✖${c.reset}`} ${msg}`);
884
+ if (!passed) { if (fix) console.log(` ${c.dim}→ ${fix}${c.reset}`); issues++; }
659
885
  };
660
886
 
661
- // ── Core files ──────────────────────────────────────────────────
887
+ // Core files
662
888
  check(fs.existsSync(path.join(cwd, 'src/lib/utils/cn.ts')),
663
889
  'src/lib/utils/cn.ts', 'run: npx basuicn init');
664
890
  check(fs.existsSync(path.join(cwd, 'src/lib/theme/themes.ts')),
@@ -668,7 +894,7 @@ const main = async () => {
668
894
  check(fs.existsSync(path.join(cwd, 'src/styles/index.css')),
669
895
  'src/styles/index.css (theme variables)', 'run: npx basuicn init');
670
896
 
671
- // ── main entry ────────────────���──────────────────────────��──────
897
+ // Main entry
672
898
  const mainPath = findMainFile(cwd);
673
899
  if (mainPath) {
674
900
  const mainContent = fs.readFileSync(mainPath, 'utf-8');
@@ -680,7 +906,7 @@ const main = async () => {
680
906
  check(false, 'main entry file (src/main.tsx)', 'create src/main.tsx');
681
907
  }
682
908
 
683
- // ── Runtime packages ─────────────────────────��──────────────────
909
+ // Runtime packages
684
910
  const pkgPath = path.join(cwd, 'package.json');
685
911
  if (fs.existsSync(pkgPath)) {
686
912
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
@@ -688,7 +914,6 @@ const main = async () => {
688
914
  for (const dep of RUNTIME_PACKAGES) {
689
915
  check(!!allDeps[dep], `package: ${dep}`, `run: npm install ${dep}`);
690
916
  }
691
- // Dev packages
692
917
  for (const dep of VITE_DEV_PACKAGES) {
693
918
  check(!!allDeps[dep], `package (dev): ${dep}`, `run: npm install -D ${dep}`);
694
919
  }
@@ -696,14 +921,14 @@ const main = async () => {
696
921
  check(false, 'package.json found', 'run: npm init -y');
697
922
  }
698
923
 
699
- // ── Config files ────────────────────���───────────────────────────
924
+ // Config files
700
925
  const hasTailwindInCss = (() => {
701
926
  const candidates = ['src/styles/index.css', 'src/index.css', 'src/App.css'];
702
927
  return candidates.some(f => {
703
928
  const p = path.join(cwd, f);
704
929
  if (!fs.existsSync(p)) return false;
705
- const c = fs.readFileSync(p, 'utf-8');
706
- return c.includes('@import "tailwindcss"') || c.includes("@import 'tailwindcss'");
930
+ const content = fs.readFileSync(p, 'utf-8');
931
+ return content.includes('@import "tailwindcss"') || content.includes("@import 'tailwindcss'");
707
932
  });
708
933
  })();
709
934
  check(hasTailwindInCss, '@import "tailwindcss" in CSS', 'run: npx basuicn init');
@@ -712,8 +937,8 @@ const main = async () => {
712
937
  const hasAlias = tsCandidates.some(f => {
713
938
  const p = path.join(cwd, f);
714
939
  if (!fs.existsSync(p)) return false;
715
- const c = fs.readFileSync(p, 'utf-8');
716
- return c.includes('"@/*"') || c.includes("'@/*'");
940
+ const content = fs.readFileSync(p, 'utf-8');
941
+ return content.includes('"@/*"') || content.includes("'@/*'");
717
942
  });
718
943
  check(hasAlias, 'TypeScript path aliases (@/*)', 'run: npx basuicn init');
719
944
 
@@ -724,35 +949,16 @@ const main = async () => {
724
949
 
725
950
  console.log('');
726
951
  if (issues === 0) {
727
- log('All checks passed! Project is healthy.');
952
+ ok(`${c.bold}All checks passed!${c.reset} Project is healthy.`);
728
953
  } else {
729
- warn(`${issues} issue(s) found. Run "npx basuicn init" to fix most issues.`);
954
+ warn(`${c.bold}${issues} issue(s) found.${c.reset} Run ${c.cyan}npx basuicn init${c.reset} to fix most issues.`);
730
955
  }
731
956
  break;
732
957
  }
733
958
 
734
959
  default: {
735
- console.log(`
736
- basuicn UI Component CLI
737
-
738
- Commands:
739
- init Set up project: install deps, copy core files, patch main entry
740
- add <name> [--force] Add component(s) to your project
741
- update <name> Update component(s) to latest registry version
742
- diff <name> Show diff between local and registry version
743
- remove <name> Remove component(s) from your project
744
- list List all available components
745
- doctor Check project health and configuration
746
-
747
- Options:
748
- --local Use local registry.json instead of remote
749
- --force Overwrite existing files when adding
750
-
751
- Quick start:
752
- npx basuicn init
753
- npx basuicn add button
754
- npx basuicn add toast
755
- `);
960
+ error(`Unknown command: "${command}"`);
961
+ console.log(` Run ${c.cyan}npx basuicn --help${c.reset} to see available commands.\n`);
756
962
  }
757
963
  }
758
964
  };