Skip to content

Commit c4779d1

Browse files
gaojudebgw
authored andcommitted
[create-next-app] Skip interactive prompts when CLI flags are provided (#91840)
When AI agents run `create-next-app` with explicit flags like `--typescript --tailwind --eslint --app --src-dir`, the CLI still enters interactive mode and prompts for any unspecified options: ``` ➜ npx create-next-app my-app --typescript --tailwind --eslint --app --src-dir --use-pnpm ✔ Would you like to use React Compiler? … No / Yes ✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes ? Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? › No / Yes ``` Agents can sometimes answer these interactive prompts correctly, but often they can't. When they fail to navigate the prompts, they fall back to scaffolding the project themselves from scratch — generating files based on stale training data. This means they might initialize an app using deprecated patterns or pin to an older version of Next.js (e.g. 15) instead of the latest. Using `create-next-app` non-interactively is the best way to ensure agents always produce up-to-date scaffolding. Previously, `hasProvidedOptions` only skipped the initial "use recommended defaults?" meta-prompt but still showed individual prompts for each missing option. Now when any config flags are provided, all remaining options use the recommended defaults without prompting. The resolved defaults are printed to stdout so the caller knows exactly what was assumed and which flags to pass to override: ``` Using defaults for unprovided options: --eslint ESLint (use --biome for Biome, --no-eslint for None) --no-react-compiler No React Compiler (use --react-compiler for React Compiler) --agents-md AGENTS.md (use --no-agents-md for No AGENTS.md) --import-alias "@/*" To customize, re-run with explicit flags. ``` The `displayConfig` array is extended with a `flags` field so this output is auto-generated from the same source of truth used for the interactive prompts. Existing behavior for `--yes`, CI mode, and fully interactive mode (no flags) is unchanged.
1 parent edcf19a commit c4779d1

4 files changed

Lines changed: 187 additions & 29 deletions

File tree

packages/create-next-app/index.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -251,19 +251,45 @@ async function run(): Promise<void> {
251251
type DisplayConfigItem = {
252252
key: keyof typeof defaults
253253
values?: Record<string, string>
254+
flags?: Record<string, string>
254255
}
255256

256257
const displayConfig: DisplayConfigItem[] = [
257258
{
258259
key: 'typescript',
259260
values: { true: 'TypeScript', false: 'JavaScript' },
261+
flags: { true: '--ts', false: '--js' },
262+
},
263+
{
264+
key: 'linter',
265+
values: { eslint: 'ESLint', biome: 'Biome', none: 'None' },
266+
flags: { eslint: '--eslint', biome: '--biome', none: '--no-eslint' },
267+
},
268+
{
269+
key: 'reactCompiler',
270+
values: { true: 'React Compiler', false: 'No React Compiler' },
271+
flags: { true: '--react-compiler', false: '--no-react-compiler' },
272+
},
273+
{
274+
key: 'tailwind',
275+
values: { true: 'Tailwind CSS', false: 'No Tailwind CSS' },
276+
flags: { true: '--tailwind', false: '--no-tailwind' },
277+
},
278+
{
279+
key: 'srcDir',
280+
values: { true: 'src/ directory', false: 'No src/ directory' },
281+
flags: { true: '--src-dir', false: '--no-src-dir' },
282+
},
283+
{
284+
key: 'app',
285+
values: { true: 'App Router', false: 'Pages Router' },
286+
flags: { true: '--app', false: '--no-app' },
287+
},
288+
{
289+
key: 'agentsMd',
290+
values: { true: 'AGENTS.md', false: 'No AGENTS.md' },
291+
flags: { true: '--agents-md', false: '--no-agents-md' },
260292
},
261-
{ key: 'linter', values: { eslint: 'ESLint', biome: 'Biome' } },
262-
{ key: 'reactCompiler', values: { true: 'React Compiler' } },
263-
{ key: 'tailwind', values: { true: 'Tailwind CSS' } },
264-
{ key: 'srcDir', values: { true: 'src/ dir' } },
265-
{ key: 'app', values: { true: 'App Router', false: 'Pages Router' } },
266-
{ key: 'agentsMd', values: { true: 'AGENTS.md' } },
267293
]
268294

269295
// Helper to format settings for display based on displayConfig
@@ -291,10 +317,17 @@ async function run(): Promise<void> {
291317
const hasSavedPreferences = Object.keys(preferences).length > 0
292318

293319
// Check if user provided any configuration flags
294-
// If they did, skip the "recommended defaults" prompt and go straight to
295-
// individual prompts for any missing options
320+
// If they did, skip all prompts and use recommended defaults for unspecified
321+
// options. This is critical for AI agents, which pass flags like
322+
// --typescript --tailwind --app and expect the rest to use sensible defaults
323+
// without entering interactive mode.
296324
const hasProvidedOptions = process.argv.some((arg) => arg.startsWith('--'))
297325

326+
if (!skipPrompt && hasProvidedOptions) {
327+
skipPrompt = true
328+
useRecommendedDefaults = true
329+
}
330+
298331
// Only show the "recommended defaults" prompt if:
299332
// - Not in CI and not using --yes flag
300333
// - User hasn't provided any custom options
@@ -620,6 +653,56 @@ async function run(): Promise<void> {
620653
preferences.agentsMd = Boolean(agentsMd)
621654
}
622655
}
656+
657+
// When prompts were skipped because flags were provided, print the
658+
// defaults that were assumed so agents and users know what to override.
659+
if (hasProvidedOptions && useRecommendedDefaults) {
660+
const lines: string[] = []
661+
662+
for (const config of displayConfig) {
663+
if (!config.flags || !config.values) continue
664+
665+
// Skip options the user already specified explicitly
666+
const wasExplicit = process.argv.some((arg) =>
667+
Object.values(config.flags!).includes(arg)
668+
)
669+
if (wasExplicit) continue
670+
671+
const value = String(defaults[config.key])
672+
const flag = config.flags[value]
673+
const label = config.values[value]
674+
if (!flag || !label) continue
675+
676+
// Show alternatives the user could pass instead
677+
const alts: string[] = []
678+
for (const [k, f] of Object.entries(config.flags)) {
679+
if (k !== value && config.values[k]) {
680+
alts.push(`${f} for ${config.values[k]}`)
681+
}
682+
}
683+
684+
const altText = alts.length > 0 ? ` (use ${alts.join(', ')})` : ''
685+
lines.push(` ${flag.padEnd(24)}${label}${altText}`)
686+
}
687+
688+
// Import alias is not a boolean toggle, handle separately
689+
const hasImportAlias = process.argv.some(
690+
(arg) =>
691+
arg.startsWith('--import-alias') ||
692+
arg.startsWith('--no-import-alias')
693+
)
694+
if (!hasImportAlias) {
695+
lines.push(` ${'--import-alias'.padEnd(24)}"${defaults.importAlias}"`)
696+
}
697+
698+
if (lines.length > 0) {
699+
console.log(
700+
'\nUsing defaults for unprovided options:\n\n' +
701+
lines.join('\n') +
702+
'\n'
703+
)
704+
}
705+
}
623706
}
624707

625708
const bundler: Bundler = opts.rspack ? Bundler.Rspack : Bundler.Turbopack

test/integration/create-next-app/examples.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,12 @@ describe('create-next-app --example', () => {
233233
[
234234
projectName,
235235
'--js',
236+
'--no-app',
236237
'--no-tailwind',
237238
'--eslint',
239+
'--no-src-dir',
240+
'--no-react-compiler',
241+
'--no-agents-md',
238242
'--example',
239243
'default',
240244
'--import-alias=@/*',

test/integration/create-next-app/index.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,88 @@ describe('create-next-app', () => {
164164
})
165165
})
166166

167+
it('should print assumed defaults when flags are partially provided', async () => {
168+
await useTempDir(async (cwd) => {
169+
const projectName = 'partial-flags'
170+
171+
const res = await run(
172+
[
173+
projectName,
174+
'--ts',
175+
'--tailwind',
176+
'--app',
177+
'--skip-install',
178+
...(process.env.NEXT_RSPACK ? ['--rspack'] : []),
179+
],
180+
nextTgzFilename,
181+
{
182+
cwd,
183+
stdio: 'pipe',
184+
}
185+
)
186+
expect(res.exitCode).toBe(0)
187+
188+
// Extract the defaults block from stdout
189+
const defaultsMatch = res.stdout.match(
190+
/Using defaults for unprovided options:\n\n([\s\S]*?)\n\nCreating/
191+
)
192+
expect(defaultsMatch).not.toBeNull()
193+
expect(defaultsMatch[1]).toMatchInlineSnapshot(`
194+
" --eslint ESLint (use --biome for Biome, --no-eslint for None)
195+
--no-react-compiler No React Compiler (use --react-compiler for React Compiler)
196+
--no-src-dir No src/ directory (use --src-dir for src/ directory)
197+
--agents-md AGENTS.md (use --no-agents-md for No AGENTS.md)
198+
--import-alias "@/*""
199+
`)
200+
})
201+
})
202+
203+
it('should not print assumed defaults when all flags are provided', async () => {
204+
await useTempDir(async (cwd) => {
205+
const projectName = 'all-flags'
206+
207+
const res = await run(
208+
[
209+
projectName,
210+
'--ts',
211+
'--app',
212+
'--eslint',
213+
'--tailwind',
214+
'--no-src-dir',
215+
'--no-import-alias',
216+
'--no-react-compiler',
217+
'--no-agents-md',
218+
'--skip-install',
219+
...(process.env.NEXT_RSPACK ? ['--rspack'] : []),
220+
],
221+
nextTgzFilename,
222+
{
223+
cwd,
224+
stdio: 'pipe',
225+
}
226+
)
227+
expect(res.exitCode).toBe(0)
228+
expect(res.stdout).not.toContain('Using defaults for unprovided options')
229+
})
230+
})
231+
232+
it('should not print assumed defaults with --yes flag', async () => {
233+
await useTempDir(async (cwd) => {
234+
const projectName = 'yes-flag'
235+
236+
const res = await run(
237+
[projectName, '--yes', '--skip-install'],
238+
nextTgzFilename,
239+
{
240+
cwd,
241+
stdio: 'pipe',
242+
}
243+
)
244+
expect(res.exitCode).toBe(0)
245+
expect(res.stdout).not.toContain('Using defaults for unprovided options')
246+
})
247+
})
248+
167249
it('should not install dependencies if --skip-install', async () => {
168250
await useTempDir(async (cwd) => {
169251
const projectName = 'empty-dir'

test/integration/create-next-app/prompts.test.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('create-next-app prompts', () => {
5757
})
5858
})
5959

60-
it('should prompt user for choice if --js or --ts flag is absent', async () => {
60+
it('should use default for --ts when other flags are provided', async () => {
6161
await useTempDir(async (cwd) => {
6262
const projectName = 'ts-js'
6363
const childProcess = createNextApp(
@@ -77,24 +77,23 @@ describe('create-next-app prompts', () => {
7777
nextTgzFilename
7878
)
7979

80+
// No stdin interaction needed - defaults are used automatically
8081
await new Promise<void>((resolve) => {
8182
childProcess.on('exit', async (exitCode) => {
8283
expect(exitCode).toBe(0)
84+
// Default is TypeScript
8385
projectFilesShouldExist({
8486
cwd,
8587
projectName,
8688
files: ['tsconfig.json'],
8789
})
8890
resolve()
8991
})
90-
91-
// select default choice: typescript
92-
childProcess.stdin.write('\n')
9392
})
9493
})
9594
})
9695

97-
it('should prompt user for choice if --tailwind is absent', async () => {
96+
it('should use default for --tailwind when other flags are provided', async () => {
9897
await useTempDir(async (cwd) => {
9998
const projectName = 'tw'
10099
const childProcess = createNextApp(
@@ -114,24 +113,23 @@ describe('create-next-app prompts', () => {
114113
nextTgzFilename
115114
)
116115

116+
// No stdin interaction needed - defaults are used automatically
117117
await new Promise<void>((resolve) => {
118118
childProcess.on('exit', async (exitCode) => {
119119
expect(exitCode).toBe(0)
120+
// Default is Tailwind enabled
120121
projectFilesShouldExist({
121122
cwd,
122123
projectName,
123124
files: ['postcss.config.mjs'],
124125
})
125126
resolve()
126127
})
127-
128-
// select default choice: tailwind
129-
childProcess.stdin.write('\n')
130128
})
131129
})
132130
})
133131

134-
it('should prompt user for choice if --import-alias is absent', async () => {
132+
it('should use default import alias when other flags are provided', async () => {
135133
await useTempDir(async (cwd) => {
136134
const projectName = 'import-alias'
137135
const childProcess = createNextApp(
@@ -151,27 +149,18 @@ describe('create-next-app prompts', () => {
151149
nextTgzFilename
152150
)
153151

154-
await new Promise<void>(async (resolve) => {
152+
// No stdin interaction needed - default import alias @/* is used
153+
await new Promise<void>((resolve) => {
155154
childProcess.on('exit', async (exitCode) => {
156155
expect(exitCode).toBe(0)
157156
resolve()
158157
})
159-
let output = ''
160-
childProcess.stdout.on('data', (data) => {
161-
output += data
162-
process.stdout.write(data)
163-
})
164-
// cursor forward, choose 'Yes' for custom import alias
165-
childProcess.stdin.write('\u001b[C\n')
166-
// used check here since it needs to wait for the prompt
167-
await check(() => output, /What import alias would you like configured/)
168-
childProcess.stdin.write('@/something/*\n')
169158
})
170159

171160
const tsConfig = require(join(cwd, projectName, 'tsconfig.json'))
172161
expect(tsConfig.compilerOptions.paths).toMatchInlineSnapshot(`
173162
{
174-
"@/something/*": [
163+
"@/*": [
175164
"./*",
176165
],
177166
}

0 commit comments

Comments
 (0)