Skip to content
250 changes: 183 additions & 67 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import process from 'node:process'

import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts'
import { box, cancel, confirm, intro, isCancel, note, outro, select, spinner, tasks, text } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
import { installDependencies } from 'nypm'
import { $fetch } from 'ofetch'
import { basename, join, relative, resolve } from 'pathe'
import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types'
import { hasTTY } from 'std-env'
import { agent, hasTTY, isAgent, isCI } from 'std-env'
import { x } from 'tinyexec'

import { runCommand } from '../run'
Expand Down Expand Up @@ -104,6 +104,16 @@ export default defineCommand({
type: 'string',
description: 'Use Nuxt nightly release channel (3x or latest)',
},
defaults: {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be named yes, it's more conventional (cf npm init -h)

type: 'boolean',
alias: 'y',
description: 'Use defaults for all prompts (useful for CI/agent environments)',
},
interactive: {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently interactive and defaults seem to only affect the same part of the code. I think interactive should not exist, it also makes it weird to have a default value of true (do you pass --no-interactive? --interactive=false?). I find the -y to be better

type: 'boolean',
default: true,
negativeDescription: 'Disable interactive prompts and use defaults',
},
},
async run(ctx) {
if (!ctx.args.offline && !ctx.args.preferOffline && !ctx.args.template) {
Expand All @@ -116,6 +126,28 @@ export default defineCommand({

intro(colors.bold(`Welcome to Nuxt!`.split('').map(m => `${themeColor}${m}`).join('')))

// Detect non-interactive environments: agent, CI, no TTY, or explicit flags
const isNonInteractive
= ctx.args.defaults === true
|| ctx.args.interactive === false
|| isAgent // AI coding agent (Claude, Cursor, Copilot, Devin, Gemini…)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will also be positive if a user is using a shell from within a coding harness.

I think it's better to gate this on whether the TTY is interactive or not, rather than whether an agent has been detected

Copy link
Copy Markdown
Member

@posva posva Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this: hasTTY should be enough. Displaying the help for the setup to be run in one go is, IMO, the best option. It's worth noting that it will also render #1287 ineffective, but I believe that's the correct path for this kind of CLI because if by any chance we want later to support agent-interactive commands, e.g. nuxi module add, we can probably opt out const isNonInteractive = !ctx.args.command && !hasTTY

|| isCI // CI/CD pipeline
|| !hasTTY // no terminal attached (piped, subprocess, etc.)

if (isNonInteractive) {
const reason = ctx.args.defaults || ctx.args.interactive === false
? 'flag'
: isAgent
? `agent (${agent})`
: isCI
? 'CI environment'
: 'no TTY'
logger.info(`Running in non-interactive mode (${reason}). Prompts will use defaults.`)
}

// Detect current package manager early so it can be shown in the options note
const currentPackageManager = detectCurrentPackageManager()

let availableTemplates: Record<string, TemplateData> = {}

if (!ctx.args.template || !ctx.args.dir) {
Expand All @@ -139,26 +171,93 @@ export default defineCommand({
}
}

// In non-interactive mode, print all available options and the exact values
// that will be used this run, so agents can discover flags and re-run with
// custom settings.
if (isNonInteractive) {
// Compute every effective value upfront so the agent sees the full picture
// before any action is taken.
const effectiveTemplate = ctx.args.template || DEFAULT_TEMPLATE_NAME
const effectiveDir = ctx.args.dir || availableTemplates[effectiveTemplate]?.defaultDir || 'nuxt-app'
const effectivePM: PackageManagerName
= (packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)
? ctx.args.packageManager as PackageManagerName
: undefined)
?? currentPackageManager
?? 'npm'
const effectiveGitInit = (ctx.args.gitInit as unknown) === 'false' ? false : (ctx.args.gitInit ?? false)
const effectiveInstall = ctx.args.install !== false && (ctx.args.install as unknown) !== 'false'
const effectiveModules = ctx.args.modules === undefined
? '(none)'
: !ctx.args.modules
? '(skipped)'
: ctx.args.modules as string

const templateLines = Object.entries(availableTemplates).map(([name, data]) =>
` ${colors.cyan(name.padEnd(18))} ${data?.description ?? ''}${name === DEFAULT_TEMPLATE_NAME ? colors.dim(' ← default') : ''}`,
)

note(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to simplify this, you could just display the help, after that display the template list (I think that's the only value that requires a specific value that isn't already mentioned in the help)

[
colors.bold('Re-run with any of these flags to customise:'),
'',
`${colors.cyan('--template')} <name>`,
...templateLines,
'',
`${colors.cyan('<dir>')} Project directory`,
`${colors.cyan('--packageManager')} <pm> npm | pnpm | yarn | bun | deno`,
`${colors.cyan('--gitInit')} / ${colors.cyan('--no-gitInit')} Initialise git repo`,
`${colors.cyan('--install')} / ${colors.cyan('--no-install')} Install dependencies`,
`${colors.cyan('--modules')} <m1,m2,...> e.g. ${colors.dim('@nuxt/content,@nuxt/ui,@nuxt/image')}`,
`${colors.dim(' Full list: https://nuxt.com/modules')}`,
`${colors.cyan('--no-modules')} Skip module prompt`,
`${colors.cyan('--force')} Override existing directory`,
`${colors.cyan('--offline')} Use cached templates`,
'',
colors.bold('Proceeding with:'),
` template: ${colors.cyan(effectiveTemplate)}`,
` directory: ${colors.cyan(effectiveDir)}`,
` packageManager: ${colors.cyan(effectivePM)}`,
` gitInit: ${colors.cyan(String(effectiveGitInit))}`,
` install: ${colors.cyan(String(effectiveInstall))}`,
` modules: ${colors.cyan(effectiveModules)}`,
].join('\n'),
'Available options',
)
Comment on lines +200 to +226
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will quickly become out of date - can we instead harness citty's own tools?

additionally it's hard-coded to be a specific width (padEnd(18)), which is not really needed for agents and is also very dependent on the content of the


// No project directory was given — the agent now has all the information
// it needs to re-run with explicit flags. Exit without creating anything.
if (!ctx.args.dir) {
outro('Re-run with a project directory to proceed, e.g: nuxi init <dir>')
process.exit(0)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be non-zero since the run didn't succeed, probably 2 because it's missing parts of the command but I don't think there is a standard on the actual value (other than being non-zero)

}
}

let templateName = ctx.args.template
if (!templateName) {
const result = await select({
message: 'Which template would you like to use?',
options: Object.entries(availableTemplates).map(([name, data]) => {
return {
value: name,
label: data ? `${colors.whiteBright(name)} – ${data.description}` : name,
hint: name === DEFAULT_TEMPLATE_NAME ? 'recommended' : undefined,
}
}),
initialValue: DEFAULT_TEMPLATE_NAME,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
if (isNonInteractive) {
templateName = DEFAULT_TEMPLATE_NAME
}
else {
const result = await select({
message: 'Which template would you like to use?',
options: Object.entries(availableTemplates).map(([name, data]) => {
return {
value: name,
label: data ? `${colors.whiteBright(name)} – ${data.description}` : name,
hint: name === DEFAULT_TEMPLATE_NAME ? 'recommended' : undefined,
}
}),
initialValue: DEFAULT_TEMPLATE_NAME,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}

templateName = result
templateName = result
}
}

// Fallback to default if still not set
Expand Down Expand Up @@ -196,6 +295,14 @@ export default defineCommand({
// when no `--force` flag is provided
const shouldVerify = !shouldForce && existsSync(templateDownloadPath)
if (shouldVerify) {
if (isNonInteractive) {
logger.error(
`Directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. `
+ `Pass ${colors.cyan('--force')} to override or specify a different directory.`,
)
process.exit(1)
}

const selectedAction = await select({
message: `The directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. What would you like to do?`,
options: [
Expand Down Expand Up @@ -319,7 +426,6 @@ export default defineCommand({
nightlySpinner.stop(`Updated to nightly version ${colors.cyan(nightlyChannelVersion)}`)
}

const currentPackageManager = detectCurrentPackageManager()
// Resolve package manager
const packageManagerArg = ctx.args.packageManager as PackageManagerName
const packageManagerSelectOptions = packageManagerOptions.map(pm => ({
Expand All @@ -332,6 +438,9 @@ export default defineCommand({
if (packageManagerOptions.includes(packageManagerArg)) {
selectedPackageManager = packageManagerArg
}
else if (isNonInteractive) {
selectedPackageManager = currentPackageManager ?? 'npm'
}
else {
const result = await select({
message: 'Which package manager would you like to use?',
Expand All @@ -350,16 +459,21 @@ export default defineCommand({
// Determine if we should init git
let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit
if (gitInit === undefined) {
const result = await confirm({
message: 'Initialize git repository?',
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
if (isNonInteractive) {
gitInit = false
}
else {
const result = await confirm({
message: 'Initialize git repository?',
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}

gitInit = result
gitInit = result
}
}

// Install project dependencies and initialize git
Expand Down Expand Up @@ -433,57 +547,59 @@ export default defineCommand({

// ...or offer to browse and install modules (if not offline)
else if (!ctx.args.offline && !ctx.args.preferOffline) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to browse and install modules?`,
initialValue: false,
})
if (!isNonInteractive) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to browse and install modules?`,
initialValue: false,
})

if (isCancel(wantsUserModules)) {
cancel('Operation cancelled.')
process.exit(1)
}
if (isCancel(wantsUserModules)) {
cancel('Operation cancelled.')
process.exit(1)
}

if (wantsUserModules) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')
if (wantsUserModules) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')

const [response, templateDeps, nuxtVersion] = await Promise.all([
modulesPromise,
getTemplateDependencies(template.dir),
getNuxtVersion(template.dir),
])
const [response, templateDeps, nuxtVersion] = await Promise.all([
modulesPromise,
getTemplateDependencies(template.dir),
getNuxtVersion(template.dir),
])

modulesSpinner.stop('Modules loaded')
modulesSpinner.stop('Modules loaded')

const allModules = response
.filter(module =>
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)
const allModules = response
.filter(module =>
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)

if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const result = await selectModulesAutocomplete({ modules: allModules })
if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const result = await selectModulesAutocomplete({ modules: allModules })

if (result.selected.length > 0) {
const modules = result.selected
if (result.selected.length > 0) {
const modules = result.selected

const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
[module, await getModuleDependencies(module)] as const,
)),
)
const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
[module, await getModuleDependencies(module)] as const,
)),
)

const { toInstall, skipped } = filterModules(modules, allDependencies)
const { toInstall, skipped } = filterModules(modules, allDependencies)

if (skipped.length) {
logger.info(`The following modules are already included as dependencies of another module and will not be installed: ${skipped.map(m => colors.cyan(m)).join(', ')}`)
if (skipped.length) {
logger.info(`The following modules are already included as dependencies of another module and will not be installed: ${skipped.map(m => colors.cyan(m)).join(', ')}`)
}
modulesToAdd.push(...toInstall)
}
modulesToAdd.push(...toInstall)
}
}
}
Expand Down
Loading