#!/usr/bin/env node
// claddcn — puts the cladd.io look onto an existing shadcn-svelte project.
//
// npx claddcn init [--cwd
] [--css ] [--only button,card] [--strip-vars] [--no-install] [--dry]
//
// What `init` does, in order:
// 1. copies claddcn.css next to the Tailwind entry named in components.json
// and imports it right after the other top-level @imports;
// 2. with --strip-vars, removes the old :root / .dark / @theme inline token
// blocks from that file (claddcn ships its own, and later ones would win);
// 3. overwrites ui components with the cladd versions (same names, same API),
// backing up whatever it replaces into .claddcn-backup//;
// 4. teaches `cn()` the cladd scale so tailwind-merge doesn't drop classes;
// 5. adds the Geist / Geist Mono fonts with the project's package manager.
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const REGISTRY_UI = path.join(ROOT, 'registry', 'ui');
const THEME_CSS = path.join(ROOT, 'src', 'claddcn.css');
const UTILS_TS = path.join(ROOT, 'registry', 'lib', 'utils.ts');
const args = process.argv.slice(2);
const command = args[0];
const flag = (name) => args.includes(`--${name}`);
const option = (name) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : undefined;
};
const cwd = path.resolve(option('cwd') ?? process.cwd());
const dry = flag('dry');
const log = (msg) => console.log(`claddcn ${msg}`);
function write(file, content) {
if (dry) return log(`[dry] write ${path.relative(cwd, file)}`);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
function copyDir(src, dest) {
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else write(to, fs.readFileSync(from));
}
}
function resolveAlias(alias, config) {
const lib = config.aliases?.lib ?? '$lib';
if (alias.startsWith('$lib')) return path.join(cwd, 'src', 'lib', alias.slice('$lib'.length));
if (alias.startsWith(lib)) return path.join(cwd, 'src', 'lib', alias.slice(lib.length));
return path.join(cwd, alias);
}
// Removes a top-level block that starts with `selector {` by brace matching.
function stripBlock(css, selector) {
const re = new RegExp(`(^|\\n)${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`);
const m = re.exec(css);
if (!m) return css;
let depth = 0;
let i = m.index + m[0].length - 1;
for (; i < css.length; i++) {
if (css[i] === '{') depth++;
else if (css[i] === '}' && --depth === 0) break;
}
return css.slice(0, m.index) + css.slice(i + 1);
}
// Tailwind entries under src/, i.e. CSS files that `@import 'tailwindcss'`.
function findTailwindEntries(dir = path.join(cwd, 'src'), found = []) {
if (!fs.existsSync(dir)) return found;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) findTailwindEntries(file, found);
else if (
entry.name.endsWith('.css') &&
entry.name !== 'claddcn.css' &&
/^@import ['"]tailwindcss['"]/m.test(fs.readFileSync(file, 'utf8'))
)
found.push(file);
}
return found;
}
// --css wins; then components.json; if that path is stale, the single Tailwind
// entry under src/ (or the one that already imports claddcn).
function resolveCss(config) {
if (option('css')) {
const file = path.join(cwd, option('css'));
if (fs.existsSync(file)) return file;
console.error(`claddcn ${option('css')} not found`);
process.exit(1);
}
const configured = path.join(cwd, config.tailwind.css);
if (fs.existsSync(configured)) return configured;
const entries = findTailwindEntries();
const ours = entries.filter((f) => fs.readFileSync(f, 'utf8').includes('claddcn.css'));
const pick = entries.length === 1 ? entries[0] : ours.length === 1 ? ours[0] : null;
if (pick) {
log(`components.json points at ${config.tailwind.css}, using ${path.relative(cwd, pick)}`);
return pick;
}
console.error(`claddcn ${config.tailwind.css} not found. Pick the Tailwind entry with --css:`);
for (const f of entries) console.error(` --css "${path.relative(cwd, f)}"`);
process.exit(1);
}
function patchCss(cssFile) {
let css = fs.readFileSync(cssFile, 'utf8');
if (!css.includes('claddcn.css')) {
const imports = [...css.matchAll(/^@import[^;]+;\s*$/gm)];
const at = imports.length ? imports.at(-1).index + imports.at(-1)[0].length : 0;
css = `${css.slice(0, at)}\n@import './claddcn.css';${css.slice(at)}`;
log(`import added to ${path.relative(cwd, cssFile)}`);
}
if (flag('strip-vars')) {
for (const selector of [':root', '.dark', '@theme inline']) css = stripBlock(css, selector);
log('old :root / .dark / @theme inline blocks removed');
} else if (/(^|\n):root\s*\{[^}]*--background/.test(css)) {
log('warning: this file still defines shadcn tokens after the import and will override');
log(' claddcn colors. Re-run with --strip-vars or delete those blocks by hand.');
}
write(cssFile, css.replace(/\n{3,}/g, '\n\n'));
}
function patchUtils(utilsFile) {
if (!fs.existsSync(utilsFile)) return write(utilsFile, fs.readFileSync(UTILS_TS));
const src = fs.readFileSync(utilsFile, 'utf8');
if (src.includes('extendTailwindMerge')) return log('cn() already extended, left as is');
const ours = fs.readFileSync(UTILS_TS, 'utf8');
const block = ours.slice(0, ours.indexOf('export function cn'));
const next = src.replace(
/import \{ clsx, type ClassValue \} from 'clsx';\s*\nimport \{ twMerge \} from 'tailwind-merge';\s*\n/,
block
);
if (next === src) {
log(
`warning: couldn't patch ${path.relative(cwd, utilsFile)} — merge registry/lib/utils.ts by hand`
);
return;
}
write(utilsFile, next);
log('cn() now knows the cladd scale');
}
function install() {
const has = (f) => fs.existsSync(path.join(cwd, f));
const pkg = '@fontsource-variable/geist @fontsource-variable/geist-mono';
const cmd =
has('bun.lock') || has('bun.lockb')
? `bun add -d ${pkg}`
: has('pnpm-lock.yaml')
? `pnpm add -D ${pkg}`
: has('yarn.lock')
? `yarn add -D ${pkg}`
: `npm i -D ${pkg}`;
if (flag('no-install') || dry) return log(`skip install, run: ${cmd}`);
log(cmd);
execSync(cmd, { cwd, stdio: 'inherit' });
}
function init() {
const configFile = path.join(cwd, 'components.json');
if (!fs.existsSync(configFile)) {
console.error(`claddcn no components.json in ${cwd} — is this a shadcn-svelte project?`);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
const cssFile = resolveCss(config);
const uiDir = resolveAlias(config.aliases?.ui ?? '$lib/components/ui', config);
const utilsFile = `${resolveAlias(config.aliases?.utils ?? '$lib/utils', config)}.ts`;
const only = option('only')?.split(',');
const available = fs.readdirSync(REGISTRY_UI);
const components = only ?? available;
const missing = components.filter((c) => !available.includes(c));
if (missing.length) log(`unknown components skipped: ${missing.join(', ')}`);
write(path.join(path.dirname(cssFile), 'claddcn.css'), fs.readFileSync(THEME_CSS));
patchCss(cssFile);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backup = path.join(cwd, '.claddcn-backup', stamp);
for (const name of components.filter((c) => available.includes(c))) {
const target = path.join(uiDir, name);
if (fs.existsSync(target) && !dry) {
fs.mkdirSync(backup, { recursive: true });
fs.cpSync(target, path.join(backup, name), { recursive: true });
}
copyDir(path.join(REGISTRY_UI, name), target);
}
log(`${components.length} components written to ${path.relative(cwd, uiDir)}`);
if (fs.existsSync(backup)) log(`previous versions saved in ${path.relative(cwd, backup)}`);
patchUtils(utilsFile);
install();
log('done — the cladd look is in. Dark mode follows the `.dark` class (mode-watcher).');
}
if (command === 'init') init();
else {
console.log(
`usage: claddcn init [--cwd ] [--css ] [--only a,b] [--strip-vars] [--no-install] [--dry]`
);
process.exit(command ? 1 : 0);
}