diff --git a/.gitignore b/.gitignore index 206cc590..806dc20d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ stats.json playground-bun* playground-deno* playground-node* +packages/nuxi/src/utils/completions-data.ts diff --git a/README.md b/README.md index 593d2a04..ed5b405f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,23 @@ All commands are listed on https://nuxt.com/docs/api/commands. +## Shell Autocompletions + +`nuxt/cli` provides shell autocompletions for commands, options, and option values – powered by [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab). + +### Package Manager Integration + +`@bomb.sh/tab` integrates with [package managers](https://github.com/bombshell-dev/tab?tab=readme-ov-file#package-manager-completions). Autocompletions work when running `nuxt` directly within a Nuxt project: + +```bash +pnpm nuxt +npm exec nuxt +yarn nuxt +bun nuxt +``` + +For package manager autocompletions, you should install [tab's package manager completions](https://github.com/bombshell-dev/tab?tab=readme-ov-file#package-manager-completions) separately. + ## Contributing ```bash diff --git a/knip.json b/knip.json index c180871c..7319ee27 100644 --- a/knip.json +++ b/knip.json @@ -28,6 +28,7 @@ "test/fixtures/*" ], "ignoreDependencies": [ + "@bomb.sh/tab", "@clack/prompts", "c12", "confbox", @@ -53,6 +54,11 @@ "ufo", "youch" ] + }, + "packages/create-nuxt": { + "ignoreDependencies": [ + "@bomb.sh/tab" + ] } } } diff --git a/package.json b/package.json index 777c3d50..efa11e15 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "nuxi": "node ./packages/nuxi/bin/nuxi.mjs", "nuxt": "node ./packages/nuxt-cli/bin/nuxi.mjs", "nuxi-bun": "bun --bun ./packages/nuxt-cli/bin/nuxi.mjs", - "postinstall": "pnpm build", + "postinstall": "node --experimental-strip-types ./scripts/generate-completions-data.ts && pnpm build", "test:types": "tsc --noEmit", "test:knip": "knip", "test:dist": "pnpm -r test:dist", @@ -30,6 +30,7 @@ "@vitest/coverage-v8": "^3.2.4", "changelogen": "^0.6.2", "eslint": "^9.39.1", + "exsolve": "^1.0.7", "knip": "^5.67.1", "nuxt": "^4.2.0", "pkg-pr-new": "^0.0.60", diff --git a/packages/create-nuxt/package.json b/packages/create-nuxt/package.json index 5cccffa7..e5a737e4 100644 --- a/packages/create-nuxt/package.json +++ b/packages/create-nuxt/package.json @@ -32,6 +32,7 @@ "citty": "^0.1.6" }, "devDependencies": { + "@bomb.sh/tab": "^0.0.9", "@types/node": "^24.10.0", "rollup": "^4.52.5", "rollup-plugin-visualizer": "^6.0.5", diff --git a/packages/create-nuxt/src/main.ts b/packages/create-nuxt/src/main.ts index 4b7a4f23..b3f3b964 100644 --- a/packages/create-nuxt/src/main.ts +++ b/packages/create-nuxt/src/main.ts @@ -3,6 +3,7 @@ import { defineCommand } from 'citty' import { provider } from 'std-env' import init from '../../nuxi/src/commands/init' +import { setupInitCompletions } from '../../nuxi/src/completions-init' import { setupGlobalConsole } from '../../nuxi/src/utils/console' import { checkEngines } from '../../nuxi/src/utils/engines' import { logger } from '../../nuxi/src/utils/logger' @@ -16,6 +17,11 @@ const _main = defineCommand({ }, args: init.args, async setup(ctx) { + const isCompletionRequest = ctx.args._?.[0] === 'complete' + if (isCompletionRequest) { + return + } + setupGlobalConsole({ dev: false }) // Check Node.js version and CLI updates in background @@ -27,4 +33,7 @@ const _main = defineCommand({ }, }) +// eslint-disable-next-line antfu/no-top-level-await +await setupInitCompletions(_main) + export const main = _main as CommandDef diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index e8f1a71a..190ee5e2 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -29,9 +29,10 @@ }, "scripts": { "build": "tsdown", - "prepack": "tsdown" + "prepack": "pnpm build" }, "devDependencies": { + "@bomb.sh/tab": "^0.0.9", "@clack/prompts": "^1.0.0-alpha.6", "@nuxt/kit": "^4.2.0", "@nuxt/schema": "^4.2.0", diff --git a/packages/nuxi/src/completions-init.ts b/packages/nuxi/src/completions-init.ts new file mode 100644 index 00000000..0799bedb --- /dev/null +++ b/packages/nuxi/src/completions-init.ts @@ -0,0 +1,27 @@ +import type { ArgsDef, CommandDef } from 'citty' +import tab from '@bomb.sh/tab/citty' +import { templates } from './utils/completions-data' + +export async function setupInitCompletions(command: CommandDef) { + const completion = await tab(command) + + const templateOption = completion.options?.get('template') + if (templateOption) { + templateOption.handler = (complete) => { + for (const template of templates) { + complete(template, '') + } + } + } + + const logLevelOption = completion.options?.get('logLevel') + if (logLevelOption) { + logLevelOption.handler = (complete) => { + complete('silent', 'No logs') + complete('info', 'Standard logging') + complete('verbose', 'Detailed logging') + } + } + + return completion +} diff --git a/packages/nuxi/src/completions.ts b/packages/nuxi/src/completions.ts new file mode 100644 index 00000000..f593b032 --- /dev/null +++ b/packages/nuxi/src/completions.ts @@ -0,0 +1,79 @@ +import type { ArgsDef, CommandDef } from 'citty' +import tab from '@bomb.sh/tab/citty' +import { nitroPresets, templates } from './utils/completions-data' + +export async function initCompletions(command: CommandDef) { + const completion = await tab(command) + + const devCommand = completion.commands.get('dev') + if (devCommand) { + const portOption = devCommand.options.get('port') + if (portOption) { + portOption.handler = (complete) => { + complete('3000', 'Default development port') + complete('3001', 'Alternative port') + complete('8080', 'Common alternative port') + } + } + + const hostOption = devCommand.options.get('host') + if (hostOption) { + hostOption.handler = (complete) => { + complete('localhost', 'Local development') + complete('0.0.0.0', 'Listen on all interfaces') + complete('127.0.0.1', 'Loopback address') + } + } + } + + const buildCommand = completion.commands.get('build') + if (buildCommand) { + const presetOption = buildCommand.options.get('preset') + if (presetOption) { + presetOption.handler = (complete) => { + for (const preset of nitroPresets) { + complete(preset, '') + } + } + } + } + + const initCommand = completion.commands.get('init') + if (initCommand) { + const templateOption = initCommand.options.get('template') + if (templateOption) { + templateOption.handler = (complete) => { + for (const template of templates) { + complete(template, '') + } + } + } + } + + const addCommand = completion.commands.get('add') + if (addCommand) { + const cwdOption = addCommand.options.get('cwd') + if (cwdOption) { + cwdOption.handler = (complete) => { + complete('.', 'Current directory') + } + } + } + + const logLevelCommands = ['dev', 'build', 'generate', 'preview', 'prepare', 'init'] + for (const cmdName of logLevelCommands) { + const cmd = completion.commands.get(cmdName) + if (cmd) { + const logLevelOption = cmd.options.get('logLevel') + if (logLevelOption) { + logLevelOption.handler = (complete) => { + complete('silent', 'No logs') + complete('info', 'Standard logging') + complete('verbose', 'Detailed logging') + } + } + } + } + + return completion +} diff --git a/packages/nuxi/src/main.ts b/packages/nuxi/src/main.ts index 0902354d..1f8f4cae 100644 --- a/packages/nuxi/src/main.ts +++ b/packages/nuxi/src/main.ts @@ -10,6 +10,7 @@ import { provider } from 'std-env' import { description, name, version } from '../package.json' import { commands } from './commands' import { cwdArgs } from './commands/_shared' +import { initCompletions } from './completions' import { setupGlobalConsole } from './utils/console' import { checkEngines } from './utils/engines' import { logger } from './utils/logger' @@ -87,4 +88,8 @@ const _main = defineCommand({ export const main = _main as CommandDef -export const runMain = (): Promise => _runMain(main) +export async function runMain(): Promise { + await initCompletions(main) + + return _runMain(main) +} diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 7208c462..2e9559b3 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -33,6 +33,7 @@ "prepack": "tsdown" }, "dependencies": { + "@bomb.sh/tab": "^0.0.9", "@clack/prompts": "^1.0.0-alpha.6", "c12": "^3.3.1", "citty": "^0.1.6", diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index d8c19d7e..eb044027 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { runCommand as _runCommand, runMain as _runMain } from 'citty' import { commands } from '../../nuxi/src/commands' +import { initCompletions } from '../../nuxi/src/completions' import { main } from './main' globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { @@ -17,7 +18,10 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { ), } -export const runMain = (): Promise => _runMain(main) +export async function runMain(): Promise { + await initCompletions(main) + return _runMain(main) +} export async function runCommand( name: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40cf7873..b2b9f65e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.6.1) + exsolve: + specifier: ^1.0.7 + version: 1.0.7 knip: specifier: ^5.67.1 version: 5.67.1(@types/node@24.10.0)(typescript@5.9.3) @@ -83,6 +86,9 @@ importers: specifier: ^0.1.6 version: 0.1.6 devDependencies: + '@bomb.sh/tab': + specifier: ^0.0.9 + version: 0.0.9(cac@6.7.14)(citty@0.1.6) '@types/node': specifier: ^24.10.0 version: 24.10.0 @@ -110,6 +116,9 @@ importers: packages/nuxi: devDependencies: + '@bomb.sh/tab': + specifier: ^0.0.9 + version: 0.0.9(cac@6.7.14)(citty@0.1.6) '@clack/prompts': specifier: ^1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -239,6 +248,9 @@ importers: packages/nuxt-cli: dependencies: + '@bomb.sh/tab': + specifier: ^0.0.9 + version: 0.0.9(cac@6.7.14)(citty@0.1.6) '@clack/prompts': specifier: ^1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -577,6 +589,21 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bomb.sh/tab@0.0.9': + resolution: {integrity: sha512-HUJ0b+LkZpLsyn0u7G/H5aJioAdSLqWMWX5ryuFS6n70MOEFu+SGrF8d8u6HzI1gINVQTvsfoxDLcjWkmI0AWg==} + hasBin: true + peerDependencies: + cac: ^6.7.14 + citty: ^0.1.6 + commander: ^13.1.0 + peerDependenciesMeta: + cac: + optional: true + citty: + optional: true + commander: + optional: true + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -5770,6 +5797,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bomb.sh/tab@0.0.9(cac@6.7.14)(citty@0.1.6)': + optionalDependencies: + cac: 6.7.14 + citty: 0.1.6 + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 diff --git a/scripts/generate-completions-data.ts b/scripts/generate-completions-data.ts new file mode 100644 index 00000000..4cc56214 --- /dev/null +++ b/scripts/generate-completions-data.ts @@ -0,0 +1,67 @@ +/** generate completion data from nitropack and Nuxt starter repo */ + +import { writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { resolveModulePath } from 'exsolve' + +interface PresetMeta { + _meta?: { name: string } +} + +const outputPath = new URL('../packages/nuxi/src/utils/completions-data.ts', import.meta.url) + +export async function generateCompletionData() { + const data = { nitroPresets: [] as string[], templates: [] as string[] } + + const nitropackPath = dirname(resolveModulePath('nitropack/package.json', { from: outputPath })) + const presetsPath = join(nitropackPath, 'dist/presets/_all.gen.mjs') + const { default: allPresets } = await import(pathToFileURL(presetsPath).toString()) as { default: PresetMeta[] } + + data.nitroPresets = allPresets + .map(preset => preset._meta?.name) + .filter((name): name is string => Boolean(name)) + .filter(name => !['base-worker', 'nitro-dev', 'nitro-prerender'].includes(name)) + .filter((name, index, array) => array.indexOf(name) === index) + .sort() + + const response = await fetch( + 'https://api.github.com/repos/nuxt/starter/contents/templates?ref=templates', + ) + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`) + } + + const files = await response.json() as Array<{ name: string, type: string }> + + const templateEntries = files + .filter((file) => { + if (file.type === 'dir') + return true + if (file.type === 'file' && file.name.endsWith('.json') && file.name !== 'content.json') { + return true + } + return false + }) + .map(file => file.name.replace('.json', '')) + + data.templates = Array.from(new Set(templateEntries)) + .filter(name => name !== 'community') + .sort() + + const content = `/** Auto-generated file */ + +export const nitroPresets = ${JSON.stringify(data.nitroPresets, null, 2)} as const + +export const templates = ${JSON.stringify(data.templates, null, 2)} as const +` + + await writeFile(outputPath, content, 'utf-8') +} + +generateCompletionData().catch((error) => { + console.error('Failed to generate completion data:', error) + process.exit(1) +})