diff --git a/cli/commands/init.mjs b/cli/commands/init.mjs
new file mode 100644
index 00000000..22590ca0
--- /dev/null
+++ b/cli/commands/init.mjs
@@ -0,0 +1,50 @@
+import { existsSync, promises as fsp } from 'node:fs'
+import { resolve } from 'pathe'
+import { defineCommand } from 'citty'
+import { consola } from 'consola'
+import { camelCase, snakeCase } from 'scule'
+import templates from '../utils/templates.mjs'
+
+export default defineCommand({
+ meta: {
+ name: 'init',
+ description: 'Init a new component.'
+ },
+ args: {
+ name: {
+ type: 'positional',
+ required: true,
+ description: 'Name of the component.'
+ }
+ },
+ async setup ({ args }) {
+ const name = args.name
+ if (!name) {
+ consola.error('name argument is missing!')
+ process.exit(1)
+ }
+
+ const path = resolve('.')
+
+ for (const template of Object.keys(templates)) {
+ const { filename, contents } = templates[template]({ name })
+ const filePath = resolve(path, filename)
+
+ if (existsSync(filePath)) {
+ consola.error(`🚨 ${filePath} already exists!`)
+ continue
+ }
+
+ await fsp.writeFile(filePath, contents.trim() + '\n')
+
+ consola.success(`🪄 Generated ${filePath}!`)
+ }
+
+ const themePath = resolve(path, 'src/theme/index.ts')
+ const theme = await fsp.readFile(themePath, 'utf-8')
+ const contents = `export { default as ${camelCase(name)} } from './${snakeCase(name)}'`
+ if (!theme.includes(contents)) {
+ await fsp.writeFile(themePath, theme.trim() + '\n' + contents + '\n')
+ }
+ }
+})
diff --git a/cli/index.mjs b/cli/index.mjs
new file mode 100644
index 00000000..a816fbe8
--- /dev/null
+++ b/cli/index.mjs
@@ -0,0 +1,14 @@
+import { defineCommand, runMain } from 'citty'
+import init from './commands/init.mjs'
+
+const main = defineCommand({
+ meta: {
+ name: 'nuxtui',
+ description: 'Nuxt UI CLI'
+ },
+ subCommands: {
+ init
+ }
+})
+
+runMain(main)
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 00000000..255877ec
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "nuxt-ui-cli",
+ "exports": {
+ ".": "./index.mjs"
+ },
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.2.3",
+ "pathe": "^1.1.2",
+ "scule": "^1.3.0"
+ }
+}
diff --git a/cli/utils/templates.mjs b/cli/utils/templates.mjs
new file mode 100644
index 00000000..e6a18f7c
--- /dev/null
+++ b/cli/utils/templates.mjs
@@ -0,0 +1,114 @@
+import { splitByCase, upperFirst, camelCase, snakeCase } from 'scule'
+
+const component = ({ name }) => {
+ const upperName = splitByCase(name).map(p => upperFirst(p))
+ const camelName = camelCase(name)
+ const snakeName = snakeCase(name)
+
+ return {
+ filename: `src/runtime/components/${upperName}.vue`,
+ contents: `
+
+
+
+
+
+
+
+ `
+ }
+}
+
+const theme = ({ name }) => {
+ const snakeName = snakeCase(name)
+
+ return {
+ filename: `src/theme/${snakeName}.ts`,
+ contents: `
+export default {
+ slots: {
+ root: ''
+ },
+ variants: {
+
+ },
+ defaultVariants: {
+
+ }
+}
+ `
+ }
+}
+
+const page = ({ name }) => {
+ const upperName = splitByCase(name).map(p => upperFirst(p))
+ const snakeName = snakeCase(name)
+
+ return {
+ filename: `playground/pages/${snakeName}.vue`,
+ contents: `
+
+
+
+
+
+ `
+ }
+}
+
+const test = ({ name }) => {
+ const upperName = splitByCase(name).map(p => upperFirst(p))
+
+ return {
+ filename: `test/components/${upperName}.spec.ts`,
+ contents: `
+import { describe, it, expect } from 'vitest'
+import ${upperName}, { type ${upperName}Props } from '../../src/runtime/components/${upperName}.vue'
+import ComponentRender from '../component-render'
+
+describe('${upperName}', () => {
+ it.each([
+ ['basic case', {}],
+ ['with class', { props: { class: '' } }],
+ ['with ui', { props: { ui: {} } }]
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props?: ${upperName}Props, slots?: any }) => {
+ const html = await ComponentRender(nameOrHtml, options, ${upperName})
+ expect(html).toMatchSnapshot()
+ })
+})
+ `
+ }
+}
+
+export default {
+ component,
+ theme,
+ page,
+ test
+}
diff --git a/package.json b/package.json
index 1ee83224..f30cfeab 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"dev": "nuxi dev playground",
"dev:build": "nuxi build playground",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
+ "cli": "node ./cli/index.mjs",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit",
"test": "vitest"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3508a8c3..02190056 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,6 +85,21 @@ importers:
specifier: ^2.0.6
version: 2.0.6(typescript@5.4.2)
+ cli:
+ dependencies:
+ citty:
+ specifier: ^0.1.6
+ version: 0.1.6
+ consola:
+ specifier: ^3.2.3
+ version: 3.2.3
+ pathe:
+ specifier: ^1.1.2
+ version: 1.1.2
+ scule:
+ specifier: ^1.3.0
+ version: 1.3.0
+
modules/dev:
dependencies:
'@nuxt/kit':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c2ff44a1..8dbbd164 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
packages:
+ - "cli"
- "playground"
- "modules/*"
diff --git a/tsconfig.json b/tsconfig.json
index 55e466b2..f537e877 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,5 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
- "exclude": ["docs", "dist", "playground", "node_modules"]
+ "exclude": ["cli", "docs", "dist", "playground", "node_modules"]
}