From 91c0d056351cb90ce26689a34adef54de5ac641e Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sat, 11 Apr 2026 21:18:23 +0200 Subject: [PATCH 1/6] CLI: Rework --dev default and ai prepare UX --- code/lib/cli-storybook/src/bin/run.ts | 9 +- code/lib/create-storybook/src/bin/run.ts | 6 +- .../src/commands/FinalizationCommand.test.ts | 136 ++++++++++- .../src/commands/FinalizationCommand.ts | 38 ++- .../commands/UserPreferencesCommand.test.ts | 220 ++++++------------ .../src/commands/UserPreferencesCommand.ts | 74 +++--- code/lib/create-storybook/src/initiate.ts | 60 +++-- .../FeatureCompatibilityService.test.ts | 47 +++- .../services/FeatureCompatibilityService.ts | 8 +- 9 files changed, 363 insertions(+), 235 deletions(-) diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 8b7e9854eb74..28ad72ca7ad8 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -3,7 +3,6 @@ import { HandledError, JsPackageManagerFactory, PackageManagerName, - isCI, optionalEnvToBoolean, removeAddon as remove, versions, @@ -106,14 +105,10 @@ command('init') .option('-y --yes', 'Answer yes to all prompts') .option('-b --builder ', 'Builder library') .option('-l --linkable', 'Prepare installation for link (contributor helper)') - .option( - '--dev', - 'Launch the development server after completing initialization. Enabled by default (default: true)', - !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) - ) + .option('--dev', 'Launch the development server after completing initialization') .option( '--no-dev', - 'Complete the initialization of Storybook without launching the Storybook development server' + 'Do not launch the Storybook development server after completing initialization (default)' ); command('add ') diff --git a/code/lib/create-storybook/src/bin/run.ts b/code/lib/create-storybook/src/bin/run.ts index 496bf53ca9af..19875786bf7b 100644 --- a/code/lib/create-storybook/src/bin/run.ts +++ b/code/lib/create-storybook/src/bin/run.ts @@ -1,5 +1,5 @@ import { ProjectType } from 'storybook/internal/cli'; -import { PackageManagerName, isCI, optionalEnvToBoolean } from 'storybook/internal/common'; +import { PackageManagerName, optionalEnvToBoolean } from 'storybook/internal/common'; import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; import { Feature, SupportedBuilder } from 'storybook/internal/types'; @@ -116,10 +116,8 @@ const createStorybookProgram = program createStorybookProgram .action(async (options) => { - const isNeitherCiNorSandbox = - !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); options.debug = options.debug ?? false; - options.dev = options.dev ?? isNeitherCiNorSandbox; + options.dev = options.dev ?? false; if (options.features === false) { // Ensure features are treated as empty when --no-features is set diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts index dfb3c87f4465..f5915b8e593f 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts @@ -7,7 +7,7 @@ import { logger } from 'storybook/internal/node-logger'; import * as find from 'empathic/find'; -import { FinalizationCommand } from './FinalizationCommand.ts'; +import { FinalizationCommand, executeFinalization } from './FinalizationCommand.ts'; vi.mock('node:fs/promises', { spy: true }); vi.mock('storybook/internal/common', { spy: true }); @@ -18,7 +18,7 @@ describe('FinalizationCommand', () => { let command: FinalizationCommand; beforeEach(() => { - command = new FinalizationCommand(undefined); + command = new FinalizationCommand(undefined, false, false); vi.mocked(getProjectRoot).mockReturnValue('/test/project'); vi.mocked(logger.step).mockImplementation(() => {}); @@ -107,4 +107,136 @@ describe('FinalizationCommand', () => { expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('ng run my-app:storybook')); }); }); + + describe('agent mode', () => { + it('should show agent-specific message when agent=true', async () => { + const agentCommand = new FinalizationCommand(undefined, true, false); + vi.mocked(find.up).mockReturnValue(undefined); + + await agentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); + }); + + it('should show standard success message when agent=false', async () => { + const nonAgentCommand = new FinalizationCommand(undefined, false, false); + vi.mocked(find.up).mockReturnValue(undefined); + + await nonAgentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('Storybook was successfully installed') + ); + // Ensure the agent message is NOT shown + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('is not entirely set up yet'))).toBe(false); + }); + }); + + describe('AI instructions', () => { + it('should show AI instructions when showAiInstructions=true', async () => { + const aiCommand = new FinalizationCommand(undefined, false, true); + vi.mocked(find.up).mockReturnValue(undefined); + + await aiCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); + }); + + it('should NOT show AI instructions when showAiInstructions=false', async () => { + const noAiCommand = new FinalizationCommand(undefined, false, false); + vi.mocked(find.up).mockReturnValue(undefined); + + await noAiCommand.execute({}); + + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('To finalize setting up with AI'))).toBe(false); + }); + + it('should show both agent message and AI instructions when both are true', async () => { + const bothCommand = new FinalizationCommand(undefined, true, true); + vi.mocked(find.up).mockReturnValue(undefined); + + await bothCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + }); + }); + + describe('storybookCommand message', () => { + it('should print "To run Storybook, run" with the command', async () => { + const cmd = new FinalizationCommand(undefined, false, false); + vi.mocked(find.up).mockReturnValue(undefined); + + await cmd.execute({ storybookCommand: 'npm run storybook' }); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('To run Storybook, run')); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('npm run storybook')); + }); + + it('should not print storybook command message when storybookCommand is null', async () => { + const cmd = new FinalizationCommand(undefined, false, false); + vi.mocked(find.up).mockReturnValue(undefined); + + await cmd.execute({ storybookCommand: null }); + + const logCalls = vi.mocked(logger.log).mock.calls.map((c) => String(c[0])); + expect(logCalls.some((msg) => msg.includes('To run Storybook, run'))).toBe(false); + }); + }); + + describe('executeFinalization helper', () => { + it('should pass agent and showAiInstructions to FinalizationCommand', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + agent: true, + showAiInstructions: false, + logfile: undefined, + }); + + // Agent mode should show agent-specific message + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + }); + + it('should pass showAiInstructions=true through to the command', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + agent: false, + showAiInstructions: true, + logfile: undefined, + }); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + }); + + it('should forward storybookCommand to execute', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + agent: false, + showAiInstructions: false, + logfile: undefined, + storybookCommand: 'yarn storybook', + }); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('yarn storybook')); + }); + }); }); diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.ts index f0bb3fbd4265..5fde0c8b58b6 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.ts @@ -22,7 +22,11 @@ type ExecuteFinalizationParams = { * - Show next steps */ export class FinalizationCommand { - constructor(private logfile: string | boolean | undefined) {} + constructor( + private logfile: string | boolean | undefined, + private agent: boolean, + private showAiInstructions: boolean + ) {} /** Execute finalization steps */ async execute({ storybookCommand }: ExecuteFinalizationParams): Promise { // Update .gitignore @@ -76,26 +80,46 @@ export class FinalizationCommand { /** Print success message with feature summary */ private printSuccessMessage(storybookCommand?: string | null): void { - logger.step(CLI_COLORS.success('Storybook was successfully installed in your project!')); + if (this.agent) { + logger.step( + CLI_COLORS.storybook( + dedent`Storybook is installed but is not entirely set up yet. + To finish setting up, now run \`npx storybook ai prepare\` and follow its instructions precisely.` + ) + ); + } else { + logger.step(CLI_COLORS.success('Storybook was successfully installed in your project!')); + } this.printNextSteps(storybookCommand); } private printNextSteps(storybookCommand?: string | null): void { if (storybookCommand) { - logger.log( - `To run Storybook manually, run ${CLI_COLORS.cta(storybookCommand)}. CTRL+C to stop.` - ); + logger.log(`To run Storybook, run ${CLI_COLORS.cta(storybookCommand)}. CTRL+C to stop.`); } logger.log(dedent` Want to learn more about Storybook? ${CLI_COLORS.cta('https://storybook.js.org/')} Having trouble or want to chat? ${CLI_COLORS.cta('https://discord.gg/storybook/')} `); + + if (this.showAiInstructions) { + logger.step(dedent`To finalize setting up with AI, paste this prompt to your AI agent: + + ${CLI_COLORS.storybook(`Run \`npx storybook ai prepare\` and follow its instructions precisely.`)} + `); + } } } export const executeFinalization = ({ + agent, logfile, + showAiInstructions, ...params -}: ExecuteFinalizationParams & { logfile: string | boolean | undefined }) => { - return new FinalizationCommand(logfile).execute(params); +}: ExecuteFinalizationParams & { + agent: boolean; + showAiInstructions: boolean; + logfile: string | boolean | undefined; +}) => { + return new FinalizationCommand(logfile, agent, showAiInstructions).execute(params); }; diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts index d82fa03080be..a2920aee7bb2 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -1,7 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AddonVitestService, ProjectType, globalSettings } from 'storybook/internal/cli'; -import type { JsPackageManager } from 'storybook/internal/common'; import { PackageManagerName, isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import type { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; @@ -10,7 +9,7 @@ import { Feature } from 'storybook/internal/types'; import type { CommandOptions } from '../generators/types.ts'; import { FeatureCompatibilityService } from '../services/FeatureCompatibilityService.ts'; import { TelemetryService } from '../services/TelemetryService.ts'; -import { UserPreferencesCommand } from './UserPreferencesCommand.ts'; +import { UserPreferencesCommand, executeUserPreferences } from './UserPreferencesCommand.ts'; vi.mock('storybook/internal/cli', { spy: true }); vi.mock('storybook/internal/common', { spy: true }); @@ -23,16 +22,21 @@ interface CommandWithPrivates { trackNewUserCheck: ReturnType; trackInstallType: ReturnType; }; - featureService: { - validateTestFeatureCompatibility: ReturnType; - }; } describe('UserPreferencesCommand', () => { let command: UserPreferencesCommand; - const mockPackageManager = {} as Partial as JsPackageManager; const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const defaultExecuteOptions = { + framework: null as null, + builder: 'vite' as SupportedBuilder, + renderer: 'react' as SupportedRenderer, + projectType: ProjectType.REACT, + isTestFeatureAvailable: true, + isAiPrepareAvailable: false, + }; + afterAll(() => { if (originalIsTTYDescriptor) { Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); @@ -51,7 +55,7 @@ describe('UserPreferencesCommand', () => { disableTelemetry: true, }; - command = new UserPreferencesCommand(commandOptions, mockPackageManager); + command = new UserPreferencesCommand(commandOptions); // Mock AddonVitestService const mockAddonVitestService = vi.fn().mockImplementation(() => ({ @@ -90,13 +94,8 @@ describe('UserPreferencesCommand', () => { trackInstallType: vi.fn(), }; - const mockFeatureService = { - validateTestFeatureCompatibility: vi.fn().mockResolvedValue({ compatible: true }), - }; - // Inject mocked services (command as unknown as CommandWithPrivates).telemetryService = mockTelemetryService; - (command as unknown as CommandWithPrivates).featureService = mockFeatureService; // Mock logger and prompt vi.mocked(logger.intro).mockImplementation(() => {}); @@ -122,10 +121,8 @@ describe('UserPreferencesCommand', () => { describe('execute', () => { it('should return recommended config for new users in non-interactive mode', async () => { const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isTestFeatureAvailable: true, }); expect(result.newUser).toBe(true); @@ -134,17 +131,6 @@ describe('UserPreferencesCommand', () => { expect(result.selectedFeatures).toContain('onboarding'); }); - it('should include AI feature by default in non-interactive mode', async () => { - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(result.selectedFeatures.has(Feature.AI)).toBe(true); - }); - it('should prompt for new user in interactive mode', async () => { // Mock TTY Object.defineProperty(process.stdout, 'isTTY', { @@ -153,14 +139,8 @@ describe('UserPreferencesCommand', () => { }); vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); + const result = await command.execute(defaultExecuteOptions); expect(prompt.select).toHaveBeenCalledWith( expect.objectContaining({ @@ -181,97 +161,53 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.select) .mockResolvedValueOnce(false) // not new user .mockResolvedValueOnce('light'); // minimal install - vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // no AI setup - - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(prompt.select).toHaveBeenCalledTimes(2); - expect(result.newUser).toBe(false); - const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; - expect(telemetryService.trackInstallType).toHaveBeenCalledWith('light'); - }); - - it('should not include test feature in minimal install', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - vi.mocked(prompt.select) - .mockResolvedValueOnce(false) // not new user - .mockResolvedValueOnce('light'); // minimal install - vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // no AI setup - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); + const result = await command.execute(defaultExecuteOptions); expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); expect(result.selectedFeatures.has(Feature.DOCS)).toBe(false); expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(false); }); - it('should validate test feature compatibility in interactive mode', async () => { + it('should remove test feature if isTestFeatureAvailable is false', async () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, }); vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup - const featureService = (command as unknown as CommandWithPrivates).featureService; - vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ - compatible: true, - }); - await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + const result = await command.execute({ + ...defaultExecuteOptions, + isTestFeatureAvailable: false, }); - expect(featureService.validateTestFeatureCompatibility).toHaveBeenCalledWith( - null, - 'vite', - process.cwd() - ); + expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); + expect(result.selectedFeatures.has(Feature.DOCS)).toBe(true); + expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(true); }); + }); - it('should remove test feature if user chooses to continue without it', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, + describe('isTestFeatureAvailable option', () => { + it('should include test feature when isTestFeatureAvailable=true in recommended install', async () => { + const result = await command.execute({ + ...defaultExecuteOptions, + isTestFeatureAvailable: true, }); - vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - const featureService = (command as unknown as CommandWithPrivates).featureService; - vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ - compatible: false, - reasons: ['React version is too old'], - }); - vi.mocked(prompt.confirm) - .mockResolvedValueOnce(true) // continue without test - .mockResolvedValueOnce(true); // AI setup + expect(result.selectedFeatures.has(Feature.TEST)).toBe(true); + }); + it('should NOT include test feature when isTestFeatureAvailable=false in recommended install', async () => { const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isTestFeatureAvailable: false, }); expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); + // Other features should still be present expect(result.selectedFeatures.has(Feature.DOCS)).toBe(true); - expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(true); + expect(result.selectedFeatures.has(Feature.A11Y)).toBe(true); }); }); @@ -286,10 +222,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).toHaveBeenCalledWith( @@ -312,10 +246,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(false); @@ -328,10 +260,8 @@ describe('UserPreferencesCommand', () => { }); const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).not.toHaveBeenCalled(); @@ -349,48 +279,24 @@ describe('UserPreferencesCommand', () => { disableTelemetry: true, yes: true, }; - const yesCommand = new UserPreferencesCommand(commandOptions, mockPackageManager); + const yesCommand = new UserPreferencesCommand(commandOptions); // Inject mocked services (yesCommand as unknown as CommandWithPrivates).telemetryService = { trackNewUserCheck: vi.fn(), trackInstallType: vi.fn(), }; - (yesCommand as unknown as CommandWithPrivates).featureService = { - validateTestFeatureCompatibility: vi.fn().mockResolvedValue({ compatible: true }), - }; const result = await yesCommand.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).not.toHaveBeenCalled(); expect(result.selectedFeatures.has(Feature.AI)).toBe(true); }); - it('should not prompt for AI setup when renderer is not react', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'vue3' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(prompt.confirm).not.toHaveBeenCalled(); - expect(result.selectedFeatures.has(Feature.AI)).toBe(false); - }); - - it('should not prompt for AI setup when builder is not vite', async () => { + it('should not prompt for AI setup when isAiPrepareAvailable is false', async () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, @@ -399,10 +305,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user const result = await command.execute({ - framework: null, - builder: 'webpack5' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: false, }); expect(prompt.confirm).not.toHaveBeenCalled(); @@ -421,10 +325,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(true); @@ -445,10 +347,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(false); @@ -456,4 +356,22 @@ describe('UserPreferencesCommand', () => { expect(result.selectedFeatures.has(Feature.DOCS)).toBe(false); }); }); + + describe('executeUserPreferences helper', () => { + it('should return a valid result', async () => { + const commandOptions: CommandOptions = { + packageManager: PackageManagerName.NPM, + disableTelemetry: true, + }; + + const result = await executeUserPreferences({ + options: commandOptions, + ...defaultExecuteOptions, + }); + + // Should return a valid result + expect(result.selectedFeatures).toBeDefined(); + expect(result.newUser).toBeDefined(); + }); + }); }); diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index a8b3ad66ffed..92ce551a18be 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -1,9 +1,13 @@ import type { ProjectType } from 'storybook/internal/cli'; import { globalSettings } from 'storybook/internal/cli'; -import { type JsPackageManager, isCI } from 'storybook/internal/common'; +import { isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; -import type { SupportedFramework } from 'storybook/internal/types'; -import { Feature, SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; +import type { + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -30,6 +34,8 @@ export interface UserPreferencesOptions { builder: SupportedBuilder; renderer: SupportedRenderer; projectType: ProjectType; + isTestFeatureAvailable: boolean; + isAiPrepareAvailable: boolean; } /** @@ -46,8 +52,6 @@ export interface UserPreferencesOptions { export class UserPreferencesCommand { constructor( private readonly commandOptions: CommandOptions, - packageManager: JsPackageManager, - private readonly featureService = new FeatureCompatibilityService(packageManager), private readonly telemetryService = new TelemetryService(commandOptions.disableTelemetry) ) {} @@ -57,11 +61,6 @@ export class UserPreferencesCommand { const isInteractive = process.stdout.isTTY && !isCI(); const skipPrompt = !isInteractive || !!this.commandOptions.yes; - const isTestFeatureAvailable = await this.isTestFeatureAvailable( - options.framework, - options.builder - ); - // Get new user preference const newUser = await this.promptNewUser(skipPrompt); @@ -77,17 +76,18 @@ export class UserPreferencesCommand { // Get install type const installType: InstallType = !newUser && !this.commandOptions.features - ? await this.promptInstallType(skipPrompt, isTestFeatureAvailable) + ? await this.promptInstallType(skipPrompt, options.isTestFeatureAvailable) : 'recommended'; - // Ask about AI setup (only available for React + Vite projects) - const isAiFeatureAvailable = this.isAiFeatureAvailable(options.renderer, options.builder); - const useAiForSetup = isAiFeatureAvailable ? await this.promptAiSetup(skipPrompt) : false; + // Ask about AI setup (only available for compatible projects, e.g. React + Vite) + const useAiForSetup = options.isAiPrepareAvailable + ? await this.promptAiSetup(skipPrompt) + : false; const selectedFeatures = this.determineFeatures( installType, newUser, - isTestFeatureAvailable, + options.isTestFeatureAvailable, options.projectType, useAiForSetup ); @@ -185,18 +185,6 @@ export class UserPreferencesCommand { return installType; } - /** Prompt user about AI-assisted Storybook setup */ - private async promptAiSetup(skipPrompt: boolean): Promise { - if (skipPrompt) { - return true; - } - - return prompt.confirm({ - message: dedent`Would you like to improve your Storybook setup with AI? - We will provide you with a prompt that you can use with your LLM to fully set up Storybook with best practices, tailored to your project.`, - }); - } - /** Determine features based on install type and user status */ private determineFeatures( installType: InstallType, @@ -230,30 +218,24 @@ export class UserPreferencesCommand { return features; } - /** Check if AI feature is available based on renderer and builder */ - private isAiFeatureAvailable(renderer: SupportedRenderer, builder: SupportedBuilder): boolean { - return renderer === SupportedRenderer.REACT && builder === SupportedBuilder.VITE; - } - - /** Validate test feature compatibility and prompt user if issues found */ - private async isTestFeatureAvailable( - framework: SupportedFramework | null, - builder: SupportedBuilder - ): Promise { - const result = await this.featureService.validateTestFeatureCompatibility( - framework, - builder, - process.cwd() - ); + /** Prompt user about AI-assisted Storybook setup */ + private async promptAiSetup(skipPrompt: boolean): Promise { + if (skipPrompt) { + return true; + } - return result.compatible; + return prompt.confirm({ + message: dedent`Would you like to improve your Storybook setup with AI? + We will install dependencies and give you a prompt to set up a Storybook that follows best practices, with content tailored to your project.`, + }); } } export const executeUserPreferences = ({ options, - packageManager, ...restOptions -}: UserPreferencesOptions & { options: CommandOptions; packageManager: JsPackageManager }) => { - return new UserPreferencesCommand(options, packageManager).execute(restOptions); +}: UserPreferencesOptions & { + options: CommandOptions; +}) => { + return new UserPreferencesCommand(options).execute(restOptions); }; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index a822fe73b2ea..173b93021f96 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -5,8 +5,13 @@ import { executeCommand, } from 'storybook/internal/common'; import { getServerPort, withTelemetry } from 'storybook/internal/core-server'; -import { CLI_COLORS, logTracker, logger } from 'storybook/internal/node-logger'; +import { logTracker, logger } from 'storybook/internal/node-logger'; import { Feature } from 'storybook/internal/types'; +import type { + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; import { executeAddonConfiguration, @@ -23,7 +28,32 @@ import { registerAllGenerators } from './generators/index.ts'; import type { CommandOptions } from './generators/types.ts'; import { FeatureCompatibilityService } from './services/FeatureCompatibilityService.ts'; import { TelemetryService } from './services/TelemetryService.ts'; -import { dedent } from 'ts-dedent'; + +/** Validate test feature compatibility and check AI prepare support */ +async function checkFeatureSupport( + packageManager: JsPackageManager, + framework: SupportedFramework | null, + builder: SupportedBuilder, + renderer: SupportedRenderer +): Promise<{ + isTestFeatureAvailable: boolean; + isAiPrepareAvailable: boolean; +}> { + const featureService = new FeatureCompatibilityService(packageManager); + + const result = await featureService.validateTestFeatureCompatibility( + framework, + builder, + process.cwd() + ); + + const aiPrepare = FeatureCompatibilityService.supportsAIPrepareFeature(renderer, builder); + + return { + isTestFeatureAvailable: result.compatible, + isAiPrepareAvailable: aiPrepare, + }; +} /** * Main entry point for Storybook initialization @@ -67,13 +97,21 @@ export async function doInitiate(options: CommandOptions): Promise< ); // Step 4: Get user preferences and feature selections (with framework/builder for validation) - const { newUser, selectedFeatures } = await executeUserPreferences({ + const { isTestFeatureAvailable, isAiPrepareAvailable } = await checkFeatureSupport( packageManager, + framework, + builder, + renderer + ); + + const { newUser, selectedFeatures } = await executeUserPreferences({ options, framework, builder, renderer, projectType, + isTestFeatureAvailable, + isAiPrepareAvailable, }); // Step 5: Execute generator with dependency collector (now with frameworkInfo) @@ -110,22 +148,11 @@ export async function doInitiate(options: CommandOptions): Promise< // Step 8: Print final summary await executeFinalization({ + agent: !!options.agent, + showAiInstructions: selectedFeatures.has(Feature.AI), logfile: options.logfile, - storybookCommand, }); - if (selectedFeatures.has(Feature.AI)) { - if (options.agent) { - const { aiPrepare } = await import('../../cli-storybook/src/ai/index.ts'); - await aiPrepare({ configDir: configDir ?? '.storybook' }); - } else { - logger.step( - CLI_COLORS.storybook(dedent`To finalize setting up with AI, paste this prompt to your AI agent: - Run \`npx storybook ai prepare\` and follow its instructions precisely.`) - ); - } - } - // Step 9: Track telemetry await telemetryService.trackInitWithContext(projectType, selectedFeatures, newUser); @@ -173,6 +200,7 @@ export async function initiate(options: CommandOptions): Promise { handleCommandFailure(options.logfile); }); + // Launch dev server only if --dev was explicitly passed if (!options.agent && initiateResult?.shouldRunDev) { await runStorybookDev(initiateResult); } diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts index 71522f775554..3f6145b596d3 100644 --- a/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AddonVitestService, ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; -import { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; import { FeatureCompatibilityService } from './FeatureCompatibilityService.ts'; @@ -44,6 +44,51 @@ describe('FeatureCompatibilityService', () => { }); }); + describe('supportsAIPrepareFeature', () => { + it('should return true for react renderer with vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.REACT, + SupportedBuilder.VITE + ) + ).toBe(true); + }); + + it('should return false for vue3 renderer with vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.VUE3, + SupportedBuilder.VITE + ) + ).toBe(false); + }); + + it('should return false for react renderer with webpack5 builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.REACT, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + }); + + it('should return false for non-react renderer with non-vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.ANGULAR, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.SVELTE, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + }); + }); + describe('validateTestFeatureCompatibility', () => { let mockValidateCompatibility: ReturnType; diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts index 032d73bcaecf..7f78a1214027 100644 --- a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts @@ -1,6 +1,7 @@ import { AddonVitestService, ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; -import type { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; +import type { SupportedFramework } from 'storybook/internal/types'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; /** Project types that support the onboarding feature */ const ONBOARDING_PROJECT_TYPES: ProjectType[] = [ @@ -32,6 +33,11 @@ export class FeatureCompatibilityService { ); } + /** Check if AI-assisted setup (storybook ai prepare) is supported for this project configuration */ + static supportsAIPrepareFeature(renderer: SupportedRenderer, builder: SupportedBuilder): boolean { + return renderer === SupportedRenderer.REACT && builder === SupportedBuilder.VITE; + } + /** * Validate all compatibility checks for test feature * From 0b5ce36f2fe8fd7711b4b3941140a6e8909e6937 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 12 Apr 2026 00:28:16 +0200 Subject: [PATCH 2/6] Only show agent success message with followup to supported frameworks --- .../create-storybook/src/commands/FinalizationCommand.ts | 9 +++++++-- code/lib/create-storybook/src/initiate.ts | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.ts index 5fde0c8b58b6..cb5f9be769d3 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.ts @@ -25,6 +25,7 @@ export class FinalizationCommand { constructor( private logfile: string | boolean | undefined, private agent: boolean, + private isAiPrepareAvailable: boolean, private showAiInstructions: boolean ) {} /** Execute finalization steps */ @@ -80,7 +81,7 @@ export class FinalizationCommand { /** Print success message with feature summary */ private printSuccessMessage(storybookCommand?: string | null): void { - if (this.agent) { + if (this.agent && this.isAiPrepareAvailable) { logger.step( CLI_COLORS.storybook( dedent`Storybook is installed but is not entirely set up yet. @@ -114,12 +115,16 @@ export class FinalizationCommand { export const executeFinalization = ({ agent, logfile, + isAiPrepareAvailable, showAiInstructions, ...params }: ExecuteFinalizationParams & { agent: boolean; showAiInstructions: boolean; + isAiPrepareAvailable: boolean; logfile: string | boolean | undefined; }) => { - return new FinalizationCommand(logfile, agent, showAiInstructions).execute(params); + return new FinalizationCommand(logfile, agent, isAiPrepareAvailable, showAiInstructions).execute( + params + ); }; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 173b93021f96..3fb8b54767a5 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -149,6 +149,7 @@ export async function doInitiate(options: CommandOptions): Promise< // Step 8: Print final summary await executeFinalization({ agent: !!options.agent, + isAiPrepareAvailable, showAiInstructions: selectedFeatures.has(Feature.AI), logfile: options.logfile, }); From 51aef293bde5ab27dc0bea8d33cf1617ef43f02e Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 12 Apr 2026 00:29:13 +0200 Subject: [PATCH 3/6] Re-add storybookCommand --- code/lib/create-storybook/src/initiate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 3fb8b54767a5..5732a261260e 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -152,6 +152,7 @@ export async function doInitiate(options: CommandOptions): Promise< isAiPrepareAvailable, showAiInstructions: selectedFeatures.has(Feature.AI), logfile: options.logfile, + storybookCommand, }); // Step 9: Track telemetry From fd96be3701e7f47a7e95558e8060a413209c4300 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 12 Apr 2026 00:44:36 +0200 Subject: [PATCH 4/6] Update FinalizationCommand tests --- .../src/commands/FinalizationCommand.test.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts index f5915b8e593f..456c2031e6bc 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts @@ -18,7 +18,7 @@ describe('FinalizationCommand', () => { let command: FinalizationCommand; beforeEach(() => { - command = new FinalizationCommand(undefined, false, false); + command = new FinalizationCommand(undefined, false, false, false); vi.mocked(getProjectRoot).mockReturnValue('/test/project'); vi.mocked(logger.step).mockImplementation(() => {}); @@ -109,8 +109,8 @@ describe('FinalizationCommand', () => { }); describe('agent mode', () => { - it('should show agent-specific message when agent=true', async () => { - const agentCommand = new FinalizationCommand(undefined, true, false); + it('should show agent-specific message when agent=true and ai prepare is supported', async () => { + const agentCommand = new FinalizationCommand(undefined, true, true, false); vi.mocked(find.up).mockReturnValue(undefined); await agentCommand.execute({}); @@ -120,9 +120,22 @@ describe('FinalizationCommand', () => { ); expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); }); + it('should show standard success message when agent=true and ai prepare is NOT supported', async () => { + const agentCommand = new FinalizationCommand(undefined, true, false, true); + vi.mocked(find.up).mockReturnValue(undefined); + + await agentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('Storybook was successfully installed') + ); + // Ensure the agent message is NOT shown + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('is not entirely set up yet'))).toBe(false); + }); it('should show standard success message when agent=false', async () => { - const nonAgentCommand = new FinalizationCommand(undefined, false, false); + const nonAgentCommand = new FinalizationCommand(undefined, false, false, false); vi.mocked(find.up).mockReturnValue(undefined); await nonAgentCommand.execute({}); @@ -138,7 +151,7 @@ describe('FinalizationCommand', () => { describe('AI instructions', () => { it('should show AI instructions when showAiInstructions=true', async () => { - const aiCommand = new FinalizationCommand(undefined, false, true); + const aiCommand = new FinalizationCommand(undefined, false, true, true); vi.mocked(find.up).mockReturnValue(undefined); await aiCommand.execute({}); @@ -150,7 +163,7 @@ describe('FinalizationCommand', () => { }); it('should NOT show AI instructions when showAiInstructions=false', async () => { - const noAiCommand = new FinalizationCommand(undefined, false, false); + const noAiCommand = new FinalizationCommand(undefined, false, false, false); vi.mocked(find.up).mockReturnValue(undefined); await noAiCommand.execute({}); @@ -160,7 +173,7 @@ describe('FinalizationCommand', () => { }); it('should show both agent message and AI instructions when both are true', async () => { - const bothCommand = new FinalizationCommand(undefined, true, true); + const bothCommand = new FinalizationCommand(undefined, true, true, true); vi.mocked(find.up).mockReturnValue(undefined); await bothCommand.execute({}); @@ -176,7 +189,7 @@ describe('FinalizationCommand', () => { describe('storybookCommand message', () => { it('should print "To run Storybook, run" with the command', async () => { - const cmd = new FinalizationCommand(undefined, false, false); + const cmd = new FinalizationCommand(undefined, false, false, false); vi.mocked(find.up).mockReturnValue(undefined); await cmd.execute({ storybookCommand: 'npm run storybook' }); @@ -186,7 +199,7 @@ describe('FinalizationCommand', () => { }); it('should not print storybook command message when storybookCommand is null', async () => { - const cmd = new FinalizationCommand(undefined, false, false); + const cmd = new FinalizationCommand(undefined, false, false, false); vi.mocked(find.up).mockReturnValue(undefined); await cmd.execute({ storybookCommand: null }); @@ -203,6 +216,7 @@ describe('FinalizationCommand', () => { await executeFinalization({ agent: true, showAiInstructions: false, + isAiPrepareAvailable: true, logfile: undefined, }); @@ -218,6 +232,7 @@ describe('FinalizationCommand', () => { await executeFinalization({ agent: false, showAiInstructions: true, + isAiPrepareAvailable: false, logfile: undefined, }); @@ -232,6 +247,7 @@ describe('FinalizationCommand', () => { await executeFinalization({ agent: false, showAiInstructions: false, + isAiPrepareAvailable: false, logfile: undefined, storybookCommand: 'yarn storybook', }); From 8d8d4fbbbe677ab906487aeb1b9c4db6c5e82bd3 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 13 Apr 2026 11:24:24 +0200 Subject: [PATCH 5/6] Track when users request AI prepare prompt in init --- code/core/src/telemetry/types.ts | 3 +- .../commands/UserPreferencesCommand.test.ts | 51 +++++++++++++++++++ .../src/commands/UserPreferencesCommand.ts | 18 +++++-- .../src/services/TelemetryService.test.ts | 26 ++++++++++ .../src/services/TelemetryService.ts | 9 ++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index dd6be062e67a..17743b78315a 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -45,7 +45,8 @@ export type EventType = | 'doctor' | 'share' | 'ghost-stories' - | 'ai-prepare'; + | 'ai-prepare' + | 'ai-prompt-nudge'; export interface Dependency { version: string | undefined; versionSpecifier?: string; diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts index a2920aee7bb2..ea92dfa8cd9d 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -21,6 +21,7 @@ interface CommandWithPrivates { telemetryService: { trackNewUserCheck: ReturnType; trackInstallType: ReturnType; + trackAiPromptNudge: ReturnType; }; } @@ -92,6 +93,7 @@ describe('UserPreferencesCommand', () => { const mockTelemetryService = { trackNewUserCheck: vi.fn(), trackInstallType: vi.fn(), + trackAiPromptNudge: vi.fn(), }; // Inject mocked services @@ -285,6 +287,7 @@ describe('UserPreferencesCommand', () => { (yesCommand as unknown as CommandWithPrivates).telemetryService = { trackNewUserCheck: vi.fn(), trackInstallType: vi.fn(), + trackAiPromptNudge: vi.fn(), }; const result = await yesCommand.execute({ @@ -355,6 +358,54 @@ describe('UserPreferencesCommand', () => { expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); expect(result.selectedFeatures.has(Feature.DOCS)).toBe(false); }); + + it('should track ai-prompt-nudge telemetry when user accepts AI setup', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes + + await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).toHaveBeenCalledWith({ skipPrompt: false }); + }); + + it('should not track ai-prompt-nudge telemetry when user declines AI setup', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no + + await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).not.toHaveBeenCalled(); + }); + + it('should track ai-prompt-nudge telemetry when AI is auto-accepted in non-interactive mode', async () => { + // Non-interactive (no TTY) with AI available — auto-accepts + const result = await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + expect(result.selectedFeatures.has(Feature.AI)).toBe(true); + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).toHaveBeenCalledWith({ skipPrompt: true }); + }); }); describe('executeUserPreferences helper', () => { diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index 92ce551a18be..adc6b70ed61b 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -220,14 +220,22 @@ export class UserPreferencesCommand { /** Prompt user about AI-assisted Storybook setup */ private async promptAiSetup(skipPrompt: boolean): Promise { + let useAi: boolean; + if (skipPrompt) { - return true; + useAi = true; + } else { + useAi = await prompt.confirm({ + message: dedent`Would you like to improve your Storybook setup with AI? + We will install dependencies and give you a prompt to set up a Storybook that follows best practices, with content tailored to your project.`, + }); } - return prompt.confirm({ - message: dedent`Would you like to improve your Storybook setup with AI? - We will install dependencies and give you a prompt to set up a Storybook that follows best practices, with content tailored to your project.`, - }); + if (useAi) { + await this.telemetryService.trackAiPromptNudge({ skipPrompt }); + } + + return useAi; } } diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts index c5f864228490..404957f7d3be 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -71,6 +71,26 @@ describe('TelemetryService', () => { expect(telemetry).toHaveBeenCalledWith('scaffolded-empty', data); }); + + it('should track ai-prompt-nudge event with context when prompt was shown', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: false }); + + expect(telemetry).toHaveBeenCalledWith('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context: { skipPrompt: false }, + }); + }); + + it('should track ai-prompt-nudge event with context when prompt was skipped', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: true }); + + expect(telemetry).toHaveBeenCalledWith('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context: { skipPrompt: true }, + }); + }); }); describe('when telemetry is disabled', () => { @@ -115,6 +135,12 @@ describe('TelemetryService', () => { expect(telemetry).not.toHaveBeenCalled(); }); + + it('should not track ai-prompt-nudge event', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: false }); + + expect(telemetry).not.toHaveBeenCalled(); + }); }); describe('trackInitWithContext', () => { diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts index 8ab7668fcdfd..56bc1ba6e9de 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -32,6 +32,15 @@ export class TelemetryService { }); } + /** Track when a user accepts the AI setup nudge prompt */ + async trackAiPromptNudge(context: { skipPrompt: boolean }): Promise { + await this.runTelemetryIfEnabled('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context, + }); + } + /** Track Playwright prompt decision (install | skip | aborted) */ async trackPlaywrightPromptDecision( result: 'installed' | 'skipped' | 'aborted' | 'failed' From 22a5bc1cbfe372deb77c9251e56e1d98ff2e8caa Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 13 Apr 2026 19:31:28 +0200 Subject: [PATCH 6/6] PR feedback --- .../src/commands/FinalizationCommand.test.ts | 72 ++++++++++++++----- .../src/commands/FinalizationCommand.ts | 35 +++++---- .../commands/UserPreferencesCommand.test.ts | 2 +- .../src/commands/UserPreferencesCommand.ts | 16 ++--- code/lib/create-storybook/src/initiate.ts | 6 +- 5 files changed, 80 insertions(+), 51 deletions(-) diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts index 456c2031e6bc..1a45fd8601a0 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts @@ -18,7 +18,11 @@ describe('FinalizationCommand', () => { let command: FinalizationCommand; beforeEach(() => { - command = new FinalizationCommand(undefined, false, false, false); + command = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(getProjectRoot).mockReturnValue('/test/project'); vi.mocked(logger.step).mockImplementation(() => {}); @@ -109,8 +113,12 @@ describe('FinalizationCommand', () => { }); describe('agent mode', () => { - it('should show agent-specific message when agent=true and ai prepare is supported', async () => { - const agentCommand = new FinalizationCommand(undefined, true, true, false); + it('should show agent-specific message when showAgentFollowUp=true', async () => { + const agentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: true, + showAiInstructions: false, + }); vi.mocked(find.up).mockReturnValue(undefined); await agentCommand.execute({}); @@ -120,8 +128,13 @@ describe('FinalizationCommand', () => { ); expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); }); - it('should show standard success message when agent=true and ai prepare is NOT supported', async () => { - const agentCommand = new FinalizationCommand(undefined, true, false, true); + + it('should show standard success message when showAgentFollowUp=false with AI instructions', async () => { + const agentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: true, + }); vi.mocked(find.up).mockReturnValue(undefined); await agentCommand.execute({}); @@ -134,8 +147,12 @@ describe('FinalizationCommand', () => { expect(stepCalls.some((msg) => msg.includes('is not entirely set up yet'))).toBe(false); }); - it('should show standard success message when agent=false', async () => { - const nonAgentCommand = new FinalizationCommand(undefined, false, false, false); + it('should show standard success message when showAgentFollowUp=false', async () => { + const nonAgentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(find.up).mockReturnValue(undefined); await nonAgentCommand.execute({}); @@ -151,7 +168,11 @@ describe('FinalizationCommand', () => { describe('AI instructions', () => { it('should show AI instructions when showAiInstructions=true', async () => { - const aiCommand = new FinalizationCommand(undefined, false, true, true); + const aiCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: true, + }); vi.mocked(find.up).mockReturnValue(undefined); await aiCommand.execute({}); @@ -163,7 +184,11 @@ describe('FinalizationCommand', () => { }); it('should NOT show AI instructions when showAiInstructions=false', async () => { - const noAiCommand = new FinalizationCommand(undefined, false, false, false); + const noAiCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(find.up).mockReturnValue(undefined); await noAiCommand.execute({}); @@ -173,7 +198,11 @@ describe('FinalizationCommand', () => { }); it('should show both agent message and AI instructions when both are true', async () => { - const bothCommand = new FinalizationCommand(undefined, true, true, true); + const bothCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: true, + showAiInstructions: true, + }); vi.mocked(find.up).mockReturnValue(undefined); await bothCommand.execute({}); @@ -189,7 +218,11 @@ describe('FinalizationCommand', () => { describe('storybookCommand message', () => { it('should print "To run Storybook, run" with the command', async () => { - const cmd = new FinalizationCommand(undefined, false, false, false); + const cmd = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(find.up).mockReturnValue(undefined); await cmd.execute({ storybookCommand: 'npm run storybook' }); @@ -199,7 +232,11 @@ describe('FinalizationCommand', () => { }); it('should not print storybook command message when storybookCommand is null', async () => { - const cmd = new FinalizationCommand(undefined, false, false, false); + const cmd = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(find.up).mockReturnValue(undefined); await cmd.execute({ storybookCommand: null }); @@ -210,13 +247,12 @@ describe('FinalizationCommand', () => { }); describe('executeFinalization helper', () => { - it('should pass agent and showAiInstructions to FinalizationCommand', async () => { + it('should show agent follow-up when showAgentFollowUp=true', async () => { vi.mocked(find.up).mockReturnValue(undefined); await executeFinalization({ - agent: true, + showAgentFollowUp: true, showAiInstructions: false, - isAiPrepareAvailable: true, logfile: undefined, }); @@ -230,9 +266,8 @@ describe('FinalizationCommand', () => { vi.mocked(find.up).mockReturnValue(undefined); await executeFinalization({ - agent: false, + showAgentFollowUp: false, showAiInstructions: true, - isAiPrepareAvailable: false, logfile: undefined, }); @@ -245,9 +280,8 @@ describe('FinalizationCommand', () => { vi.mocked(find.up).mockReturnValue(undefined); await executeFinalization({ - agent: false, + showAgentFollowUp: false, showAiInstructions: false, - isAiPrepareAvailable: false, logfile: undefined, storybookCommand: 'yarn storybook', }); diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.ts index cb5f9be769d3..78348f277fef 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.ts @@ -7,6 +7,14 @@ import { ErrorCollector } from 'storybook/internal/telemetry'; import * as find from 'empathic/find'; import { dedent } from 'ts-dedent'; +export type FinalizationCommandOptions = { + logfile: string | boolean | undefined; + /** When true, show the agent follow-up message ("run npx storybook ai prepare") instead of the normal success message */ + showAgentFollowUp: boolean; + /** When true, show the "paste this prompt to your AI agent" instructions */ + showAiInstructions: boolean; +}; + type ExecuteFinalizationParams = { storybookCommand?: string | null; }; @@ -22,12 +30,8 @@ type ExecuteFinalizationParams = { * - Show next steps */ export class FinalizationCommand { - constructor( - private logfile: string | boolean | undefined, - private agent: boolean, - private isAiPrepareAvailable: boolean, - private showAiInstructions: boolean - ) {} + constructor(private options: FinalizationCommandOptions) {} + /** Execute finalization steps */ async execute({ storybookCommand }: ExecuteFinalizationParams): Promise { // Update .gitignore @@ -74,14 +78,14 @@ export class FinalizationCommand { this.printNextSteps(storybookCommand); try { - const logFile = await logTracker.writeToFile(this.logfile); + const logFile = await logTracker.writeToFile(this.options.logfile); logger.warn(`Debug logs are written to: ${logFile}`); } catch {} } /** Print success message with feature summary */ private printSuccessMessage(storybookCommand?: string | null): void { - if (this.agent && this.isAiPrepareAvailable) { + if (this.options.showAgentFollowUp) { logger.step( CLI_COLORS.storybook( dedent`Storybook is installed but is not entirely set up yet. @@ -104,7 +108,7 @@ export class FinalizationCommand { Having trouble or want to chat? ${CLI_COLORS.cta('https://discord.gg/storybook/')} `); - if (this.showAiInstructions) { + if (this.options.showAiInstructions) { logger.step(dedent`To finalize setting up with AI, paste this prompt to your AI agent: ${CLI_COLORS.storybook(`Run \`npx storybook ai prepare\` and follow its instructions precisely.`)} @@ -112,19 +116,14 @@ export class FinalizationCommand { } } } + export const executeFinalization = ({ - agent, logfile, - isAiPrepareAvailable, + showAgentFollowUp, showAiInstructions, ...params -}: ExecuteFinalizationParams & { - agent: boolean; - showAiInstructions: boolean; - isAiPrepareAvailable: boolean; - logfile: string | boolean | undefined; -}) => { - return new FinalizationCommand(logfile, agent, isAiPrepareAvailable, showAiInstructions).execute( +}: ExecuteFinalizationParams & FinalizationCommandOptions) => { + return new FinalizationCommand({ logfile, showAgentFollowUp, showAiInstructions }).execute( params ); }; diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts index ea92dfa8cd9d..f62b63ce47c2 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -231,7 +231,7 @@ describe('UserPreferencesCommand', () => { expect(prompt.confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( - 'Would you like to improve your Storybook setup with AI?' + 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?' ), }) ); diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index adc6b70ed61b..f16a6af3c815 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -220,16 +220,12 @@ export class UserPreferencesCommand { /** Prompt user about AI-assisted Storybook setup */ private async promptAiSetup(skipPrompt: boolean): Promise { - let useAi: boolean; - - if (skipPrompt) { - useAi = true; - } else { - useAi = await prompt.confirm({ - message: dedent`Would you like to improve your Storybook setup with AI? - We will install dependencies and give you a prompt to set up a Storybook that follows best practices, with content tailored to your project.`, - }); - } + const useAi = skipPrompt + ? true + : await prompt.confirm({ + message: + 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?', + }); if (useAi) { await this.telemetryService.trackAiPromptNudge({ skipPrompt }); diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 5732a261260e..e8ec6c27e546 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -147,10 +147,10 @@ export async function doInitiate(options: CommandOptions): Promise< }); // Step 8: Print final summary + const hasAiFeature = selectedFeatures.has(Feature.AI); await executeFinalization({ - agent: !!options.agent, - isAiPrepareAvailable, - showAiInstructions: selectedFeatures.has(Feature.AI), + showAgentFollowUp: !!options.agent && hasAiFeature, + showAiInstructions: hasAiFeature, logfile: options.logfile, storybookCommand, });