From e8e52032b5ceda14e7e6c53457c43240c829b5e3 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 24 Sep 2026 18:05:11 +0200 Subject: [PATCH 01/12] feat(cli): install the official Storybook skills on init and upgrade Add a shared `installSkills` step that installs the storybookjs/skills repo through Vercel's `skills` CLI into the project's git root, asks once per project and remembers the answer under `agentSkills` in the global settings file. `storybook init` runs it after addon configuration and `storybook upgrade` once per run; both grow `--skills` / `--no-skills`. CI skips the step, agents and `-y` install without asking, and a failed install only warns. Package managers gain `getRemoteRunCommand` so the printed command matches the remote runner that is actually used. Co-Authored-By: Claude Fable 5.1 --- code/core/src/cli/globalSettings.ts | 1 + code/core/src/cli/index.ts | 1 + code/core/src/cli/installSkills.test.ts | 347 ++++++++++++++++++ code/core/src/cli/installSkills.ts | 175 +++++++++ .../src/common/js-package-manager/BUNProxy.ts | 4 +- .../js-package-manager/JsPackageManager.ts | 3 + .../src/common/js-package-manager/NPMProxy.ts | 4 + .../common/js-package-manager/PNPMProxy.ts | 4 + .../common/js-package-manager/Yarn1Proxy.ts | 4 + .../common/js-package-manager/Yarn2Proxy.ts | 4 + code/lib/cli-storybook/src/bin/run.ts | 2 + .../cli-storybook/src/upgrade.skills.test.ts | 117 ++++++ code/lib/cli-storybook/src/upgrade.ts | 14 +- code/lib/create-storybook/src/bin/run.ts | 2 + .../create-storybook/src/generators/types.ts | 2 + code/lib/create-storybook/src/initiate.ts | 16 +- .../src/services/TelemetryService.test.ts | 17 + .../src/services/TelemetryService.ts | 9 + 18 files changed, 720 insertions(+), 6 deletions(-) create mode 100644 code/core/src/cli/installSkills.test.ts create mode 100644 code/core/src/cli/installSkills.ts create mode 100644 code/lib/cli-storybook/src/upgrade.skills.test.ts diff --git a/code/core/src/cli/globalSettings.ts b/code/core/src/cli/globalSettings.ts index ac10a2c82474..9b9b9f12fed3 100644 --- a/code/core/src/cli/globalSettings.ts +++ b/code/core/src/cli/globalSettings.ts @@ -24,6 +24,7 @@ const userSettingSchema = z.object({ // (we can remove keys once they are deprecated) userSince: z.number().optional(), init: z.object({ skipOnboarding: z.boolean().optional() }).optional(), + agentSkills: z.record(z.string(), z.boolean()).optional(), checklist: z .object({ items: z diff --git a/code/core/src/cli/index.ts b/code/core/src/cli/index.ts index 9de23de5c109..6e17cd94363c 100644 --- a/code/core/src/cli/index.ts +++ b/code/core/src/cli/index.ts @@ -6,5 +6,6 @@ export * from './NpmOptions.ts'; export * from './eslintPlugin.ts'; export * from './globalSettings.ts'; export * from './AddonVitestService.ts'; +export * from './installSkills.ts'; export * from './detectLanguage.ts'; export * from './getStorybookData.ts'; diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts new file mode 100644 index 000000000000..5937e48780c1 --- /dev/null +++ b/code/core/src/cli/installSkills.test.ts @@ -0,0 +1,347 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { executeCommand, getProjectRoot, isCI } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { ExecaCommandFailedError } from 'storybook/internal/server-errors'; +import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; + +import { vol } from 'memfs'; + +import { _clearGlobalSettings } from './globalSettings.ts'; +import { decideSkillsInstall, installSkills } from './installSkills.ts'; + +const versionHolder = vi.hoisted(() => ({ storybook: '10.6.0' })); + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('storybook/internal/telemetry', { spy: true }); +vi.mock(import('storybook/internal/common'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProjectRoot: vi.fn(), + isCI: vi.fn(), + executeCommand: vi.fn(), + get versions() { + return { ...actual.versions, storybook: versionHolder.storybook }; + }, + }; +}); + +const PROJECT_ROOT = '/test/project'; +const SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json'); +const INSTALL_ARGS = (ref: string) => [ + 'skills@latest', + 'add', + `storybookjs/skills#${ref}`, + '-y', + '-a', + 'claude-code', + 'universal', + '--copy', +]; + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const setStdoutIsTTY = (value: boolean) => + Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); + +const settingsFile = () => JSON.parse(vol.toJSON()[SETTINGS_PATH] as string); +const seedSettings = (agentSkills?: Record) => + vol.fromNestedJSON({ + [SETTINGS_PATH]: JSON.stringify({ version: 1, userSince: 1, agentSkills }), + }); +const tagFound = () => + vi.mocked(executeCommand).mockResolvedValue({ stdout: 'abc\trefs/tags/v10.6.0\n' } as never); +const tagMissing = () => vi.mocked(executeCommand).mockResolvedValue({ stdout: '' } as never); + +describe('decideSkillsInstall', () => { + const base = { isCI: false, isInteractive: true, yes: false, agent: false }; + + it.each([ + [ + '--skills wins over everything', + { ...base, skillsFlag: true, isCI: true, remembered: false }, + { action: 'install', source: 'flag' }, + ], + [ + '--no-skills wins over a remembered true', + { ...base, skillsFlag: false, remembered: true, agent: true }, + { action: 'skip', source: 'flag' }, + ], + [ + 'CI skips even with -y and an agent', + { ...base, isCI: true, isInteractive: false, yes: true, agent: true, remembered: true }, + { action: 'skip', source: 'ci' }, + ], + [ + 'a remembered true installs', + { ...base, remembered: true }, + { action: 'install', source: 'settings' }, + ], + [ + 'a remembered false skips even for an agent', + { ...base, remembered: false, agent: true, yes: true }, + { action: 'skip', source: 'settings' }, + ], + [ + 'an agent installs', + { ...base, agent: true, yes: true }, + { action: 'install', source: 'agent' }, + ], + ['-y installs', { ...base, yes: true }, { action: 'install', source: 'yes' }], + ['interactive asks', { ...base }, { action: 'ask', source: 'prompt' }], + [ + 'non-interactive without -y takes the default', + { ...base, isInteractive: false }, + { action: 'install', source: 'default' }, + ], + ] as const)('%s', (_, input, expected) => { + expect(decideSkillsInstall(input)).toEqual(expected); + }); +}); + +describe('installSkills', () => { + let packageManager: JsPackageManager; + + beforeEach(async () => { + const memfs = await vi.importActual('memfs'); + vi.mocked(mkdir).mockImplementation(memfs.fs.promises.mkdir as unknown as typeof mkdir); + vi.mocked(writeFile).mockImplementation( + memfs.fs.promises.writeFile as unknown as typeof writeFile + ); + vi.mocked(readFile).mockImplementation( + memfs.fs.promises.readFile as unknown as typeof readFile + ); + vol.reset(); + _clearGlobalSettings(); + versionHolder.storybook = '10.6.0'; + + packageManager = { + runPackageCommand: vi.fn().mockResolvedValue(undefined), + getRemoteRunCommand: vi.fn((args: string[]) => `npx ${args.join(' ')}`), + } as Partial as JsPackageManager; + + vi.mocked(getProjectRoot).mockReturnValue(PROJECT_ROOT); + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(isTelemetryModuleEnabled).mockReturnValue(true); + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); + setStdoutIsTTY(true); + tagFound(); + }); + + afterEach(() => { + if (originalIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } + vol.reset(); + }); + + describe('the command', () => { + it('installs from the version tag when it exists', async () => { + const result = await installSkills({ packageManager, yes: true }); + + expect(packageManager.runPackageCommand).toHaveBeenCalledWith({ + args: INSTALL_ARGS('v10.6.0'), + useRemotePkg: true, + cwd: PROJECT_ROOT, + stdio: 'inherit', + env: { npm_config_yes: 'true' }, + }); + expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'tag' }); + }); + + it('falls back to main for a release without a tag', async () => { + tagMissing(); + + const result = await installSkills({ packageManager, yes: true }); + + expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( + INSTALL_ARGS('main') + ); + expect(result.refType).toBe('branch'); + }); + + it('falls back to next for a prerelease without a tag', async () => { + versionHolder.storybook = '11.0.0-alpha.1'; + tagMissing(); + + await installSkills({ packageManager, yes: true }); + + expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( + INSTALL_ARGS('next') + ); + }); + + it('treats a failing tag lookup as a missing tag', async () => { + vi.mocked(executeCommand).mockRejectedValue(new Error('no network')); + + const result = await installSkills({ packageManager, yes: true }); + + expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( + INSTALL_ARGS('main') + ); + expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'branch' }); + }); + + it.each([ + ['a prerelease', '11.0.0-alpha.1', true], + ['disabled Storybook telemetry', '10.6.0', false], + ])('disables Vercel telemetry for %s', async (_, version, telemetryEnabled) => { + versionHolder.storybook = version; + vi.mocked(isTelemetryModuleEnabled).mockReturnValue(telemetryEnabled); + + await installSkills({ packageManager, yes: true }); + + expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].env).toEqual({ + npm_config_yes: 'true', + DISABLE_TELEMETRY: '1', + }); + }); + + it('prints the literal command and the skip line', async () => { + await installSkills({ packageManager, yes: true }); + + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'npx skills@latest add storybookjs/skills#v10.6.0 -y -a claude-code universal --copy' + ) + ); + expect(logger.log).toHaveBeenCalledWith( + 'Skip this next time with --no-skills. Remove them with: npx skills@latest remove' + ); + }); + }); + + describe('failure', () => { + it('warns, keeps the settings untouched and reports the exit code', async () => { + vi.mocked(packageManager.runPackageCommand).mockRejectedValue( + new ExecaCommandFailedError({ command: 'npx', args: [], exitCode: 1, logs: '' }) + ); + + const result = await installSkills({ packageManager, yes: true }); + + expect(result).toEqual({ result: 'failed', source: 'yes', refType: 'tag', exitCode: 1 }); + expect(logger.warn).toHaveBeenCalledWith( + 'Could not install the Storybook skills, continuing without them.' + ); + expect(settingsFile().agentSkills).toBeUndefined(); + }); + }); + + describe('the settings', () => { + it('refreshes silently when the project remembered true', async () => { + seedSettings({ [PROJECT_ROOT]: true }); + + const result = await installSkills({ packageManager }); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'installed', source: 'settings', refType: 'tag' }); + }); + + it('skips silently when the project remembered false', async () => { + seedSettings({ [PROJECT_ROOT]: false }); + + const result = await installSkills({ packageManager, yes: true, agent: true }); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(packageManager.runPackageCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped', source: 'settings' }); + }); + + it('asks when another project is remembered but this one is not', async () => { + seedSettings({ '/other/project': true }); + + await installSkills({ packageManager }); + + expect(prompt.confirm).toHaveBeenCalled(); + }); + + it('treats an unreadable settings file as no answer', async () => { + vol.fromNestedJSON({ [SETTINGS_PATH]: 'not json' }); + + await installSkills({ packageManager }); + + expect(prompt.confirm).toHaveBeenCalled(); + }); + + it.each([ + ['Yes in the prompt', {}], + ['-y', { yes: true }], + ['an agent', { agent: true }], + ['--skills', { skillsFlag: true }], + ])('remembers true after %s', async (_, options) => { + await installSkills({ packageManager, ...options }); + + expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: true }); + }); + + it('lets --skills replace a remembered false', async () => { + seedSettings({ [PROJECT_ROOT]: false }); + + const result = await installSkills({ packageManager, skillsFlag: true }); + + expect(result).toEqual({ result: 'installed', source: 'flag', refType: 'tag' }); + expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: true }); + }); + + it('remembers false after --no-skills', async () => { + const result = await installSkills({ packageManager, skillsFlag: false }); + + expect(packageManager.runPackageCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped', source: 'flag' }); + expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: false }); + }); + + it('writes nothing under CI', async () => { + vi.mocked(isCI).mockReturnValue(true); + + const result = await installSkills({ packageManager, yes: true, agent: true }); + + expect(packageManager.runPackageCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped', source: 'ci' }); + expect(settingsFile().agentSkills).toBeUndefined(); + }); + }); + + describe('the prompt', () => { + it('installs on Yes', async () => { + const result = await installSkills({ packageManager }); + + expect(prompt.confirm).toHaveBeenCalledWith({ + message: + 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + initialValue: true, + }); + expect(result).toEqual({ result: 'installed', source: 'prompt', refType: 'tag' }); + }); + + it('declines on No, remembers false and spawns nothing', async () => { + vi.mocked(prompt.confirm).mockResolvedValue(false); + + const result = await installSkills({ packageManager }); + + expect(packageManager.runPackageCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'declined', source: 'prompt' }); + expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: false }); + }); + + it('takes the default without asking when stdout is not a terminal', async () => { + setStdoutIsTTY(false); + + const result = await installSkills({ packageManager }); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'installed', source: 'default', refType: 'tag' }); + }); + }); +}); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts new file mode 100644 index 000000000000..9f4467e22169 --- /dev/null +++ b/code/core/src/cli/installSkills.ts @@ -0,0 +1,175 @@ +import type { JsPackageManager } from 'storybook/internal/common'; +import { executeCommand, getProjectRoot, isCI, versions } from 'storybook/internal/common'; +import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; +import { ExecaCommandFailedError } from 'storybook/internal/server-errors'; +import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; + +import { prerelease } from 'semver'; + +import { globalSettings } from './globalSettings.ts'; + +const SKILLS_REPO = 'storybookjs/skills'; + +export type SkillsSource = 'flag' | 'ci' | 'settings' | 'agent' | 'yes' | 'prompt' | 'default'; + +export type SkillsDecision = { + action: 'install' | 'skip' | 'ask'; + source: SkillsSource; +}; + +export type SkillsInstallResult = { + result: 'installed' | 'declined' | 'skipped' | 'failed'; + source: SkillsSource; + refType?: 'tag' | 'branch'; + exitCode?: number; +}; + +/** Decide whether init or upgrade should install the official Storybook skills. */ +export function decideSkillsInstall(input: { + skillsFlag?: boolean; + isCI: boolean; + isInteractive: boolean; + yes: boolean; + agent: boolean; + remembered?: boolean; +}): SkillsDecision { + if (input.skillsFlag === true) { + return { action: 'install', source: 'flag' }; + } + if (input.skillsFlag === false) { + return { action: 'skip', source: 'flag' }; + } + if (input.isCI) { + return { action: 'skip', source: 'ci' }; + } + if (input.remembered === true) { + return { action: 'install', source: 'settings' }; + } + if (input.remembered === false) { + return { action: 'skip', source: 'settings' }; + } + if (input.agent) { + return { action: 'install', source: 'agent' }; + } + if (input.yes) { + return { action: 'install', source: 'yes' }; + } + return input.isInteractive + ? { action: 'ask', source: 'prompt' } + : { action: 'install', source: 'default' }; +} + +async function resolveSkillsRef( + storybookVersion: string +): Promise<{ ref: string; refType: 'tag' | 'branch' }> { + const tag = `v${storybookVersion}`; + try { + const { stdout } = await executeCommand({ + command: 'git', + args: ['ls-remote', '--tags', `https://github.com/${SKILLS_REPO}`, `refs/tags/${tag}`], + stdio: 'pipe', + }); + if (stdout.trim()) { + return { ref: tag, refType: 'tag' }; + } + } catch (error) { + logger.debug(error); + } + return { ref: prerelease(storybookVersion) ? 'next' : 'main', refType: 'branch' }; +} + +/** + * Install the official Storybook skills into the project's git root through Vercel's `skills` + * CLI, remembering the answer per project in the global settings file. Never throws. + */ +export async function installSkills({ + packageManager, + skillsFlag, + yes = false, + agent = false, +}: { + packageManager: JsPackageManager; + skillsFlag?: boolean; + yes?: boolean; + agent?: boolean; +}): Promise { + const projectRoot = getProjectRoot(); + const settings = await globalSettings(); + const remember = async (answer: boolean) => { + if (settings.value.agentSkills?.[projectRoot] === answer) { + return; + } + settings.value.agentSkills = { ...settings.value.agentSkills, [projectRoot]: answer }; + await settings.save(); + }; + + const decision = decideSkillsInstall({ + skillsFlag, + isCI: !!isCI(), + isInteractive: !!process.stdout.isTTY, + yes, + agent, + remembered: settings.value.agentSkills?.[projectRoot], + }); + + if (decision.action === 'ask') { + const accepted = await prompt.confirm({ + message: + 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + initialValue: true, + }); + if (!accepted) { + await remember(false); + return { result: 'declined', source: decision.source }; + } + } + + if (decision.action === 'skip') { + if (decision.source === 'flag') { + await remember(false); + } + return { result: 'skipped', source: decision.source }; + } + + const { ref, refType } = await resolveSkillsRef(versions.storybook); + const args = [ + 'skills@latest', + 'add', + `${SKILLS_REPO}#${ref}`, + '-y', + '-a', + 'claude-code', + 'universal', + '--copy', + ]; + const env: Record = { npm_config_yes: 'true' }; + if (prerelease(versions.storybook) || !isTelemetryModuleEnabled()) { + env.DISABLE_TELEMETRY = '1'; + } + + logger.log(CLI_COLORS.cta(packageManager.getRemoteRunCommand(args))); + try { + await packageManager.runPackageCommand({ + args, + useRemotePkg: true, + cwd: projectRoot, + stdio: 'inherit', + env, + }); + } catch (error) { + logger.warn('Could not install the Storybook skills, continuing without them.'); + logger.debug(error); + return { + result: 'failed', + source: decision.source, + refType, + exitCode: error instanceof ExecaCommandFailedError ? error.data.exitCode : undefined, + }; + } + + await remember(true); + logger.log( + `Skip this next time with --no-skills. Remove them with: ${packageManager.getRemoteRunCommand(['skills@latest', 'remove'])}` + ); + return { result: 'installed', source: decision.source, refType }; +} diff --git a/code/core/src/common/js-package-manager/BUNProxy.ts b/code/core/src/common/js-package-manager/BUNProxy.ts index b45e70e03f47..a5a86233689c 100644 --- a/code/core/src/common/js-package-manager/BUNProxy.ts +++ b/code/core/src/common/js-package-manager/BUNProxy.ts @@ -105,8 +105,8 @@ export class BUNProxy extends JsPackageManager { return `bun run ${command}`; } - getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string { - return `bunx ${pkg}${specifier ? `@${specifier}` : ''} ${args.join(' ')}`; + getRemoteRunCommand(args: string[]): string { + return `bunx ${args.join(' ')}`; } getPackageCommand(args: string[]): string { diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 405c45937e15..d061f12e8fa6 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -156,6 +156,9 @@ export abstract class JsPackageManager { /** Returns the command to run the binary of a local package */ abstract getPackageCommand(args: string[]): string; + /** Returns the command to run the binary of a remote package, as `runPackageCommand` does with `useRemotePkg` */ + abstract getRemoteRunCommand(args: string[]): string; + /** Get the package.json file for a given module. */ abstract getModulePackageJSON(packageName: string, cwd?: string): Promise; diff --git a/code/core/src/common/js-package-manager/NPMProxy.ts b/code/core/src/common/js-package-manager/NPMProxy.ts index 6d17213593a2..43fa1bfb7511 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.ts @@ -126,6 +126,10 @@ export class NPMProxy extends JsPackageManager { return `npx ${args.join(' ')}`; } + public getRemoteRunCommand(args: string[]): string { + return `npx ${args.join(' ')}`; + } + public runPackageCommand( options: Omit & { args: string[] } ): ResultPromise { diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index eb8b6a634563..44057b380f8e 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -129,6 +129,10 @@ export class PNPMProxy extends JsPackageManager { return `pnpm exec ${args.join(' ')}`; } + getRemoteRunCommand(args: string[]): string { + return `pnpm dlx ${args.join(' ')}`; + } + public runPackageCommand({ args, useRemotePkg = false, diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.ts index 1e3198c19ff1..9a307f9ddb9e 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.ts @@ -63,6 +63,10 @@ export class Yarn1Proxy extends JsPackageManager { return `yarn exec ${command} -- ${rest.join(' ')}`; } + getRemoteRunCommand(args: string[]): string { + return `npx ${args.join(' ')}`; + } + public runPackageCommand({ args, useRemotePkg = false, diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index 7f2393c1f4fc..23d769bd0dd4 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -119,6 +119,10 @@ export class Yarn2Proxy extends JsPackageManager { return `yarn exec ${args.join(' ')}`; } + getRemoteRunCommand(args: string[]): string { + return `yarn dlx ${args.join(' ')}`; + } + public runPackageCommand({ args, useRemotePkg = false, diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index d0a491d04518..35281f604338 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -166,6 +166,8 @@ command('upgrade') ) ) .option('-y --yes', 'Skip prompting the user') + .option('--skills', 'Install the official Storybook skills for AI agents without asking') + .option('--no-skills', 'Skip installing the official Storybook skills for AI agents') .addOption( new Option( '--features ', diff --git a/code/lib/cli-storybook/src/upgrade.skills.test.ts b/code/lib/cli-storybook/src/upgrade.skills.test.ts new file mode 100644 index 000000000000..8200a668bd70 --- /dev/null +++ b/code/lib/cli-storybook/src/upgrade.skills.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installSkills } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { detectAgent, telemetry } from 'storybook/internal/telemetry'; + +import { runAutomigrations } from './automigrate/multi-project.ts'; +import { displayDoctorResults, runMultiProjectDoctor } from './doctor/index.ts'; +import { type UpgradeOptions, upgrade } from './upgrade.ts'; +import { type CollectProjectsSuccessResult, getProjects } from './util.ts'; + +vi.mock('storybook/internal/cli', { spy: true }); +vi.mock('storybook/internal/telemetry'); +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('./automigrate/multi-project.ts', { spy: true }); +vi.mock('./doctor/index.ts', { spy: true }); +vi.mock('./util.ts', { spy: true }); +vi.mock(import('storybook/internal/common'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + JsPackageManagerFactory: Object.assign(actual.JsPackageManagerFactory, { + getPackageManager: () => ({ + type: 'npm', + installDependencies: vi.fn(), + isStorybookInMonorepo: () => false, + }), + }), + }; +}); + +const project = (configDir: string): CollectProjectsSuccessResult => + ({ + configDir, + mainConfigPath: `${configDir}/main.ts`, + mainConfig: {}, + beforeVersion: '9.0.0', + currentCLIVersion: '11.0.0', + isCanary: false, + autoblockerCheckResults: null, + packageManager: { + type: 'npm', + packageJsonPaths: [], + precheckStorybookPackageInstall: vi.fn(), + } as Partial as JsPackageManager, + }) as unknown as CollectProjectsSuccessResult; + +const baseOptions: UpgradeOptions = { + skipCheck: true, + dryRun: false, + yes: true, + force: false, + disableTelemetry: false, +}; + +describe('upgrade: the skills step', () => { + const projects = [project('/repo/a/.storybook'), project('/repo/b/.storybook')]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getProjects).mockResolvedValue({ allProjects: projects, selectedProjects: projects }); + vi.mocked(runAutomigrations).mockResolvedValue({ + automigrationResults: {}, + detectedAutomigrations: [], + }); + vi.mocked(runMultiProjectDoctor).mockResolvedValue({}); + vi.mocked(displayDoctorResults).mockReturnValue(false); + vi.mocked(detectAgent).mockReturnValue({ name: 'claude' }); + for (const method of ['info', 'step', 'log', 'warn', 'debug'] as const) { + vi.mocked(logger[method]).mockImplementation(() => {}); + } + vi.mocked(prompt.taskLog).mockReturnValue({ + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType); + vi.mocked(installSkills).mockResolvedValue({ + result: 'installed', + source: 'prompt', + refType: 'tag', + }); + }); + + it('runs once for the whole upgrade with the options derived from the CLI flags', async () => { + await upgrade({ ...baseOptions, skills: false }); + + expect(installSkills).toHaveBeenCalledTimes(1); + expect(installSkills).toHaveBeenCalledWith({ + packageManager: expect.objectContaining({ type: 'npm' }), + skillsFlag: false, + yes: true, + agent: true, + }); + }); + + it("attaches the result to every project's upgrade event", async () => { + await upgrade(baseOptions); + + const upgradeEvents = vi + .mocked(telemetry) + .mock.calls.filter(([eventType]) => eventType === 'upgrade') + .map(([, payload]) => payload); + expect(upgradeEvents).toHaveLength(2); + for (const event of upgradeEvents) { + expect(event).toMatchObject({ + skills: { result: 'installed', source: 'prompt', refType: 'tag' }, + }); + } + }); + + it('never runs during a dry run', async () => { + await upgrade({ ...baseOptions, dryRun: true }); + + expect(installSkills).not.toHaveBeenCalled(); + }); +}); diff --git a/code/lib/cli-storybook/src/upgrade.ts b/code/lib/cli-storybook/src/upgrade.ts index cabc63ee102e..a8a9718244b3 100644 --- a/code/lib/cli-storybook/src/upgrade.ts +++ b/code/lib/cli-storybook/src/upgrade.ts @@ -1,3 +1,4 @@ +import { installSkills } from 'storybook/internal/cli'; import { PackageManagerName } from 'storybook/internal/common'; import { HandledError, @@ -20,7 +21,7 @@ import { UpgradeStorybookToLowerVersionError, UpgradeStorybookUnknownCurrentVersionError, } from 'storybook/internal/server-errors'; -import { telemetry } from 'storybook/internal/telemetry'; +import { detectAgent, telemetry } from 'storybook/internal/telemetry'; import { sync as spawnSync } from 'cross-spawn'; import picocolors from 'picocolors'; @@ -143,6 +144,7 @@ export type UpgradeOptions = { packageManager?: PackageManagerName; dryRun: boolean; yes: boolean; + skills?: boolean; features?: string; force: boolean; disableTelemetry: boolean; @@ -548,6 +550,15 @@ export async function upgrade(options: UpgradeOptions): Promise { } } + const skills = options.dryRun + ? undefined + : await installSkills({ + packageManager: rootPackageManager, + skillsFlag: options.skills, + yes: options.yes, + agent: !!detectAgent(), + }); + // Run doctor for each project const doctorProjects: ProjectDoctorData[] = storybookProjects.map((project) => ({ configDir: project.configDir, @@ -605,6 +616,7 @@ export async function upgrade(options: UpgradeOptions): Promise { doctorResults: doctorResults[project.configDir]?.diagnostics || {}, doctorFailureCount, doctorErrorCount, + skills, }); } diff --git a/code/lib/create-storybook/src/bin/run.ts b/code/lib/create-storybook/src/bin/run.ts index b74ebd564f39..63d5a04a8d32 100644 --- a/code/lib/create-storybook/src/bin/run.ts +++ b/code/lib/create-storybook/src/bin/run.ts @@ -74,6 +74,8 @@ const createStorybookProgram = program ) .option('--agent', 'Force agent mode (non-interactive, logs AI setup instructions)') .option('--no-agent', 'Force disable agent mode even when an AI agent is detected') + .option('--skills', 'Install the official Storybook skills for AI agents without asking') + .option('--no-skills', 'Skip installing the official Storybook skills for AI agents') .option( '--logfile [path]', 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' diff --git a/code/lib/create-storybook/src/generators/types.ts b/code/lib/create-storybook/src/generators/types.ts index 92ece1fa4349..4271e5b3b00a 100644 --- a/code/lib/create-storybook/src/generators/types.ts +++ b/code/lib/create-storybook/src/generators/types.ts @@ -145,6 +145,8 @@ export type CommandOptions = { force?: any; /** Whether this is being run via an ai agent */ agent?: boolean; + /** `--skills` / `--no-skills`: install the official Storybook skills without asking, or never */ + skills?: boolean; html?: boolean; skipInstall?: boolean; language?: SupportedLanguage; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 7c370be35751..b297629dfb8b 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path'; -import { ProjectType } from 'storybook/internal/cli'; +import { ProjectType, installSkills } from 'storybook/internal/cli'; import { HandledError, PackageManagerName, @@ -164,7 +164,17 @@ export async function doInitiate(options: CommandOptions): Promise< options, }); - // Step 8: Print final summary + // Step 8: Install the official Storybook skills for AI agents + await telemetryService.trackSkills( + await installSkills({ + packageManager, + skillsFlag: options.skills, + yes: options.yes, + agent: options.agent, + }) + ); + + // Step 9: Print final summary const hasAiFeature = selectedFeatures.has(Feature.AI); if (configDir && isAiSetupAvailable) { // Persist init-time AI opt-in/opt-out so the dev server can gate AI-related UI @@ -193,7 +203,7 @@ export async function doInitiate(options: CommandOptions): Promise< setupSkillCommand: packageManager.getPackageCommand(['storybook', 'skills', 'setup']), }); - // Step 9: Track telemetry (pass configDir so RN `.rnstorybook` metadata is resolved) + // Step 10: Track telemetry (pass configDir so RN `.rnstorybook` metadata is resolved) await telemetryService.trackInitWithContext(projectType, selectedFeatures, newUser, configDir); // Signal dev to redirect to onboarding on first run diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts index be1553f75592..96d309317c69 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -44,6 +44,23 @@ describe('TelemetryService', () => { }); }); + it('should track the skills step', async () => { + await telemetryService.trackSkills({ + result: 'failed', + source: 'yes', + refType: 'branch', + exitCode: 1, + }); + + expect(telemetry).toHaveBeenCalledWith('init-step', { + step: 'skills', + result: 'failed', + source: 'yes', + refType: 'branch', + exitCode: 1, + }); + }); + it('should track init event', async () => { const data = { projectType: ProjectType.REACT, diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts index 3df2301c60f8..4a505d2e7f48 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -3,6 +3,7 @@ import { getProcessAncestry, getStorybookVersionSpecifierFromAncestry, } from 'storybook/internal/common'; +import type { SkillsInstallResult } from 'storybook/internal/cli'; import { telemetry } from 'storybook/internal/telemetry'; import { Feature } from 'storybook/internal/types'; @@ -47,6 +48,14 @@ export class TelemetryService { }); } + /** Track the outcome of the official Storybook skills install step */ + async trackSkills(result: SkillsInstallResult): Promise { + await telemetry('init-step', { + step: 'skills', + ...result, + }); + } + /** Track the main init event with all metadata */ async trackInit( data: { From 10e1af63fafa1a67de83c6c8d0cb2a1e8558fadf Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 25 Sep 2026 09:39:10 +0200 Subject: [PATCH 02/12] fix(cli): narrow the execa stdout and exit code types in installSkills Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 9f4467e22169..059c625b2685 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -69,7 +69,7 @@ async function resolveSkillsRef( args: ['ls-remote', '--tags', `https://github.com/${SKILLS_REPO}`, `refs/tags/${tag}`], stdio: 'pipe', }); - if (stdout.trim()) { + if (typeof stdout === 'string' && stdout.trim()) { return { ref: tag, refType: 'tag' }; } } catch (error) { @@ -163,7 +163,10 @@ export async function installSkills({ result: 'failed', source: decision.source, refType, - exitCode: error instanceof ExecaCommandFailedError ? error.data.exitCode : undefined, + exitCode: + error instanceof ExecaCommandFailedError && typeof error.data.exitCode === 'number' + ? error.data.exitCode + : undefined, }; } From e0eb1dc21c2c10e7d505fc29eaa1b40eddbff9b3 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 30 Sep 2026 17:36:29 +0200 Subject: [PATCH 03/12] fix(cli): harden the skills install step - Esc or Ctrl+C at the skills prompt skips the step without remembering the answer, instead of ending init or upgrade. - The tag lookup never asks for git credentials and gives up after 10 seconds. - A failed install also reports its exit code on pnpm and Yarn Berry, whose failures arrive as PackageInstallFailedError. - The printed pnpm command includes --allow-build=esbuild when the executed command does. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 63 +++++++++++++------ code/core/src/cli/installSkills.ts | 31 ++++++--- .../js-package-manager/PNPMProxy.test.ts | 12 ++++ .../common/js-package-manager/PNPMProxy.ts | 3 +- 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index 5937e48780c1..981c4a3138e2 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -7,7 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { JsPackageManager } from 'storybook/internal/common'; import { executeCommand, getProjectRoot, isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; -import { ExecaCommandFailedError } from 'storybook/internal/server-errors'; +import { + ExecaCommandFailedError, + PackageInstallFailedError, +} from 'storybook/internal/server-errors'; import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; import { vol } from 'memfs'; @@ -223,19 +226,25 @@ describe('installSkills', () => { }); describe('failure', () => { - it('warns, keeps the settings untouched and reports the exit code', async () => { - vi.mocked(packageManager.runPackageCommand).mockRejectedValue( - new ExecaCommandFailedError({ command: 'npx', args: [], exitCode: 1, logs: '' }) - ); - - const result = await installSkills({ packageManager, yes: true }); - - expect(result).toEqual({ result: 'failed', source: 'yes', refType: 'tag', exitCode: 1 }); - expect(logger.warn).toHaveBeenCalledWith( - 'Could not install the Storybook skills, continuing without them.' - ); - expect(settingsFile().agentSkills).toBeUndefined(); - }); + it.each([ + ['npx', ExecaCommandFailedError], + ['pnpm', PackageInstallFailedError], + ])( + 'warns, keeps the settings untouched and reports the exit code (%s)', + async (command, Err) => { + vi.mocked(packageManager.runPackageCommand).mockRejectedValue( + new Err({ command, args: [], exitCode: 1, logs: '' }) + ); + + const result = await installSkills({ packageManager, yes: true }); + + expect(result).toEqual({ result: 'failed', source: 'yes', refType: 'tag', exitCode: 1 }); + expect(logger.warn).toHaveBeenCalledWith( + 'Could not install the Storybook skills, continuing without them.' + ); + expect(settingsFile().agentSkills).toBeUndefined(); + } + ); }); describe('the settings', () => { @@ -317,11 +326,14 @@ describe('installSkills', () => { it('installs on Yes', async () => { const result = await installSkills({ packageManager }); - expect(prompt.confirm).toHaveBeenCalledWith({ - message: - 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', - initialValue: true, - }); + expect(prompt.confirm).toHaveBeenCalledWith( + { + message: + 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + initialValue: true, + }, + expect.anything() + ); expect(result).toEqual({ result: 'installed', source: 'prompt', refType: 'tag' }); }); @@ -335,6 +347,19 @@ describe('installSkills', () => { expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: false }); }); + it('skips without remembering when the prompt is canceled', async () => { + vi.mocked(prompt.confirm).mockImplementation(async (_, promptOptions) => { + await promptOptions?.onCancel?.(); + return true; + }); + + const result = await installSkills({ packageManager }); + + expect(packageManager.runPackageCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'declined', source: 'prompt' }); + expect(settingsFile().agentSkills).toBeUndefined(); + }); + it('takes the default without asking when stdout is not a terminal', async () => { setStdoutIsTTY(false); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 059c625b2685..0db78a5d4292 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -1,7 +1,6 @@ import type { JsPackageManager } from 'storybook/internal/common'; import { executeCommand, getProjectRoot, isCI, versions } from 'storybook/internal/common'; import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; -import { ExecaCommandFailedError } from 'storybook/internal/server-errors'; import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; import { prerelease } from 'semver'; @@ -68,6 +67,8 @@ async function resolveSkillsRef( command: 'git', args: ['ls-remote', '--tags', `https://github.com/${SKILLS_REPO}`, `refs/tags/${tag}`], stdio: 'pipe', + env: { GIT_TERMINAL_PROMPT: '0' }, + timeout: 10_000, }); if (typeof stdout === 'string' && stdout.trim()) { return { ref: tag, refType: 'tag' }; @@ -113,11 +114,22 @@ export async function installSkills({ }); if (decision.action === 'ask') { - const accepted = await prompt.confirm({ - message: - 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', - initialValue: true, - }); + let canceled = false; + const accepted = await prompt.confirm( + { + message: + 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + initialValue: true, + }, + { + onCancel: () => { + canceled = true; + }, + } + ); + if (canceled) { + return { result: 'declined', source: decision.source }; + } if (!accepted) { await remember(false); return { result: 'declined', source: decision.source }; @@ -159,14 +171,13 @@ export async function installSkills({ } catch (error) { logger.warn('Could not install the Storybook skills, continuing without them.'); logger.debug(error); + // pnpm and Yarn Berry failures arrive as PackageInstallFailedError because the args contain `add` + const exitCode = (error as { data?: { exitCode?: unknown } } | undefined)?.data?.exitCode; return { result: 'failed', source: decision.source, refType, - exitCode: - error instanceof ExecaCommandFailedError && typeof error.data.exitCode === 'number' - ? error.data.exitCode - : undefined, + exitCode: typeof exitCode === 'number' ? exitCode : undefined, }; } diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index b677f44f1e0f..116ceb0a5540 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -145,6 +145,12 @@ describe('PNPM Proxy', () => { }) ); }); + + it('should print --allow-build=esbuild before dlx', () => { + expect(pnpmProxy.getRemoteRunCommand(['create-storybook@10.5.5', '-y'])).toBe( + 'pnpm --allow-build=esbuild dlx create-storybook@10.5.5 -y' + ); + }); }); describe('on pnpm < 10.2', () => { @@ -168,6 +174,12 @@ describe('PNPM Proxy', () => { }) ); }); + + it('should not print --allow-build on dlx', () => { + expect(pnpmProxy.getRemoteRunCommand(['create-storybook@10.5.5', '-y'])).toBe( + 'pnpm dlx create-storybook@10.5.5 -y' + ); + }); }); }); }); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 44057b380f8e..15d44d609f0a 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -130,7 +130,8 @@ export class PNPMProxy extends JsPackageManager { } getRemoteRunCommand(args: string[]): string { - return `pnpm dlx ${args.join(' ')}`; + const allowBuild = this.#pnpmGte(PNPM_ALLOW_BUILD_DLX_MIN) ? '--allow-build=esbuild ' : ''; + return `pnpm ${allowBuild}dlx ${args.join(' ')}`; } public runPackageCommand({ From 8a3f54aced2a1a7a2bdb2f3654cc5b36367b9c80 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 30 Sep 2026 17:47:21 +0200 Subject: [PATCH 04/12] fix(cli): skip the skills step in storybook sandbox `storybook sandbox` resolves the project root before it changes into the new sandbox, and inside a repository that root is the user's repository anyway. The skills landed there and the answer was remembered for it. A reproduction sandbox now skips the step and remembers nothing. Also covers the tag lookup's credential and timeout guard with a test. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 10 +++++++++- code/core/src/cli/installSkills.ts | 2 +- code/lib/cli-storybook/src/sandbox.ts | 1 + .../create-storybook/src/generators/types.ts | 2 ++ code/lib/create-storybook/src/initiate.ts | 18 ++++++++++-------- .../src/services/TelemetryService.ts | 3 +-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index 981c4a3138e2..fed866e8d29d 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -185,6 +185,14 @@ describe('installSkills', () => { ); }); + it('never asks for git credentials and gives up on the tag lookup after 10 seconds', async () => { + await installSkills({ packageManager, yes: true }); + + expect(executeCommand).toHaveBeenCalledWith( + expect.objectContaining({ env: { GIT_TERMINAL_PROMPT: '0' }, timeout: 10_000 }) + ); + }); + it('treats a failing tag lookup as a missing tag', async () => { vi.mocked(executeCommand).mockRejectedValue(new Error('no network')); @@ -347,7 +355,7 @@ describe('installSkills', () => { expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: false }); }); - it('skips without remembering when the prompt is canceled', async () => { + it('declines without remembering when the prompt is canceled', async () => { vi.mocked(prompt.confirm).mockImplementation(async (_, promptOptions) => { await promptOptions?.onCancel?.(); return true; diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 0db78a5d4292..7894e4f0809d 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -171,7 +171,7 @@ export async function installSkills({ } catch (error) { logger.warn('Could not install the Storybook skills, continuing without them.'); logger.debug(error); - // pnpm and Yarn Berry failures arrive as PackageInstallFailedError because the args contain `add` + // pnpm and Yarn Berry failures arrive as package-install errors because the args contain `add` const exitCode = (error as { data?: { exitCode?: unknown } } | undefined)?.data?.exitCode; return { result: 'failed', diff --git a/code/lib/cli-storybook/src/sandbox.ts b/code/lib/cli-storybook/src/sandbox.ts index c066250b2e4d..5f59df0ebc0b 100644 --- a/code/lib/cli-storybook/src/sandbox.ts +++ b/code/lib/cli-storybook/src/sandbox.ts @@ -230,6 +230,7 @@ export const sandbox = async ({ ...options, ...(selectedConfig.initOptions || {}), features: ['docs', 'test'], + skipSkills: true, }); process.chdir(before); } diff --git a/code/lib/create-storybook/src/generators/types.ts b/code/lib/create-storybook/src/generators/types.ts index 4271e5b3b00a..7c174eb99a8e 100644 --- a/code/lib/create-storybook/src/generators/types.ts +++ b/code/lib/create-storybook/src/generators/types.ts @@ -147,6 +147,8 @@ export type CommandOptions = { agent?: boolean; /** `--skills` / `--no-skills`: install the official Storybook skills without asking, or never */ skills?: boolean; + /** Skip the skills step without remembering an answer, for throwaway projects like `storybook sandbox` */ + skipSkills?: boolean; html?: boolean; skipInstall?: boolean; language?: SupportedLanguage; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index b297629dfb8b..4d575b4b56a6 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -165,14 +165,16 @@ export async function doInitiate(options: CommandOptions): Promise< }); // Step 8: Install the official Storybook skills for AI agents - await telemetryService.trackSkills( - await installSkills({ - packageManager, - skillsFlag: options.skills, - yes: options.yes, - agent: options.agent, - }) - ); + if (!options.skipSkills) { + await telemetryService.trackSkills( + await installSkills({ + packageManager, + skillsFlag: options.skills, + yes: options.yes, + agent: options.agent, + }) + ); + } // Step 9: Print final summary const hasAiFeature = selectedFeatures.has(Feature.AI); diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts index 4a505d2e7f48..fccfee4fc732 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -1,9 +1,8 @@ -import type { ProjectType } from 'storybook/internal/cli'; +import type { ProjectType, SkillsInstallResult } from 'storybook/internal/cli'; import { getProcessAncestry, getStorybookVersionSpecifierFromAncestry, } from 'storybook/internal/common'; -import type { SkillsInstallResult } from 'storybook/internal/cli'; import { telemetry } from 'storybook/internal/telemetry'; import { Feature } from 'storybook/internal/types'; From 0cea333992dce456998fecbea8db5cae4b7340bf Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 30 Sep 2026 22:02:39 +0200 Subject: [PATCH 05/12] test(cli): return promises from the package manager stubs in the upgrade skills test Co-Authored-By: Claude Opus 5.5 (1M context) --- code/lib/cli-storybook/src/upgrade.skills.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/lib/cli-storybook/src/upgrade.skills.test.ts b/code/lib/cli-storybook/src/upgrade.skills.test.ts index 8200a668bd70..65891891c909 100644 --- a/code/lib/cli-storybook/src/upgrade.skills.test.ts +++ b/code/lib/cli-storybook/src/upgrade.skills.test.ts @@ -23,7 +23,7 @@ vi.mock(import('storybook/internal/common'), async (importOriginal) => { JsPackageManagerFactory: Object.assign(actual.JsPackageManagerFactory, { getPackageManager: () => ({ type: 'npm', - installDependencies: vi.fn(), + installDependencies: vi.fn().mockResolvedValue(undefined), isStorybookInMonorepo: () => false, }), }), @@ -42,7 +42,7 @@ const project = (configDir: string): CollectProjectsSuccessResult => packageManager: { type: 'npm', packageJsonPaths: [], - precheckStorybookPackageInstall: vi.fn(), + precheckStorybookPackageInstall: vi.fn().mockResolvedValue(undefined), } as Partial as JsPackageManager, }) as unknown as CollectProjectsSuccessResult; From ca09b4b81ecd2c5583d927ca1105815fdb6aa74e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 1 Oct 2026 10:47:23 +0200 Subject: [PATCH 06/12] fix(cli): bound the skills install and make its tests portable Stop the skills CLI after two minutes so a stalled network cannot hang the end of init or upgrade. Read the settings file through memfs instead of by key, which also works with Windows paths, and assert that remembering one project keeps the answers of other projects. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 13 +++++++++---- code/core/src/cli/installSkills.ts | 6 ++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index fed866e8d29d..6529fe6517f6 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -53,7 +53,7 @@ const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); const setStdoutIsTTY = (value: boolean) => Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); -const settingsFile = () => JSON.parse(vol.toJSON()[SETTINGS_PATH] as string); +const settingsFile = () => JSON.parse(vol.readFileSync(SETTINGS_PATH, 'utf8') as string); const seedSettings = (agentSkills?: Record) => vol.fromNestedJSON({ [SETTINGS_PATH]: JSON.stringify({ version: 1, userSince: 1, agentSkills }), @@ -159,6 +159,7 @@ describe('installSkills', () => { cwd: PROJECT_ROOT, stdio: 'inherit', env: { npm_config_yes: 'true' }, + timeout: 120_000, }); expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'tag' }); }); @@ -185,7 +186,7 @@ describe('installSkills', () => { ); }); - it('never asks for git credentials and gives up on the tag lookup after 10 seconds', async () => { + it('looks up the tag without a credential prompt and with a 10 second timeout', async () => { await installSkills({ packageManager, yes: true }); expect(executeCommand).toHaveBeenCalledWith( @@ -275,12 +276,16 @@ describe('installSkills', () => { expect(result).toEqual({ result: 'skipped', source: 'settings' }); }); - it('asks when another project is remembered but this one is not', async () => { - seedSettings({ '/other/project': true }); + it('asks when another project is remembered but this one is not, and keeps both answers', async () => { + seedSettings({ '/other/project': false }); await installSkills({ packageManager }); expect(prompt.confirm).toHaveBeenCalled(); + expect(settingsFile().agentSkills).toEqual({ + '/other/project': false, + [PROJECT_ROOT]: true, + }); }); it('treats an unreadable settings file as no answer', async () => { diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 7894e4f0809d..39c39b7a11ef 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -80,8 +80,9 @@ async function resolveSkillsRef( } /** - * Install the official Storybook skills into the project's git root through Vercel's `skills` - * CLI, remembering the answer per project in the global settings file. Never throws. + * Install the official Storybook skills into the project root through Vercel's `skills` CLI, + * remembering the answer per project in the global settings file. A failed install is reported in + * the result instead of thrown. */ export async function installSkills({ packageManager, @@ -167,6 +168,7 @@ export async function installSkills({ cwd: projectRoot, stdio: 'inherit', env, + timeout: 120_000, }); } catch (error) { logger.warn('Could not install the Storybook skills, continuing without them.'); From fbc30594e58561ff04fe622f4d480bb73eafa3b0 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 1 Oct 2026 11:16:43 +0200 Subject: [PATCH 07/12] fix(cli): ask the skills question only when stdin is a terminal too With stdin redirected, the confirm prompt never resolves and init or upgrade ends at the question. Take the prompt's default in that case, as for a redirected stdout. Also turn the package manager stubs in the upgrade skills test into plain async functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 32 ++++++++++++------- code/core/src/cli/installSkills.ts | 2 +- .../cli-storybook/src/upgrade.skills.test.ts | 4 +-- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index 6529fe6517f6..1c747d2f9ed3 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -49,9 +49,12 @@ const INSTALL_ARGS = (ref: string) => [ '--copy', ]; -const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); -const setStdoutIsTTY = (value: boolean) => - Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); +const terminalStreams = [process.stdin, process.stdout]; +const originalIsTTY = terminalStreams.map((stream) => + Object.getOwnPropertyDescriptor(stream, 'isTTY') +); +const setIsTTY = (stream: NodeJS.ReadStream | NodeJS.WriteStream, value: boolean) => + Object.defineProperty(stream, 'isTTY', { value, configurable: true }); const settingsFile = () => JSON.parse(vol.readFileSync(SETTINGS_PATH, 'utf8') as string); const seedSettings = (agentSkills?: Record) => @@ -136,16 +139,20 @@ describe('installSkills', () => { vi.mocked(logger.log).mockImplementation(() => {}); vi.mocked(logger.warn).mockImplementation(() => {}); vi.mocked(logger.debug).mockImplementation(() => {}); - setStdoutIsTTY(true); + setIsTTY(process.stdin, true); + setIsTTY(process.stdout, true); tagFound(); }); afterEach(() => { - if (originalIsTTY) { - Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); - } else { - delete (process.stdout as { isTTY?: boolean }).isTTY; - } + terminalStreams.forEach((stream, index) => { + const original = originalIsTTY[index]; + if (original) { + Object.defineProperty(stream, 'isTTY', original); + } else { + delete (stream as { isTTY?: boolean }).isTTY; + } + }); vol.reset(); }); @@ -373,8 +380,11 @@ describe('installSkills', () => { expect(settingsFile().agentSkills).toBeUndefined(); }); - it('takes the default without asking when stdout is not a terminal', async () => { - setStdoutIsTTY(false); + it.each([ + ['stdout', process.stdout], + ['stdin', process.stdin], + ])('takes the default without asking when %s is not a terminal', async (_, stream) => { + setIsTTY(stream, false); const result = await installSkills({ packageManager }); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 39c39b7a11ef..a019e01866f6 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -108,7 +108,7 @@ export async function installSkills({ const decision = decideSkillsInstall({ skillsFlag, isCI: !!isCI(), - isInteractive: !!process.stdout.isTTY, + isInteractive: !!process.stdout.isTTY && !!process.stdin.isTTY, yes, agent, remembered: settings.value.agentSkills?.[projectRoot], diff --git a/code/lib/cli-storybook/src/upgrade.skills.test.ts b/code/lib/cli-storybook/src/upgrade.skills.test.ts index 65891891c909..0e9779e0b7bc 100644 --- a/code/lib/cli-storybook/src/upgrade.skills.test.ts +++ b/code/lib/cli-storybook/src/upgrade.skills.test.ts @@ -23,7 +23,7 @@ vi.mock(import('storybook/internal/common'), async (importOriginal) => { JsPackageManagerFactory: Object.assign(actual.JsPackageManagerFactory, { getPackageManager: () => ({ type: 'npm', - installDependencies: vi.fn().mockResolvedValue(undefined), + installDependencies: async () => {}, isStorybookInMonorepo: () => false, }), }), @@ -42,7 +42,7 @@ const project = (configDir: string): CollectProjectsSuccessResult => packageManager: { type: 'npm', packageJsonPaths: [], - precheckStorybookPackageInstall: vi.fn().mockResolvedValue(undefined), + precheckStorybookPackageInstall: async () => {}, } as Partial as JsPackageManager, }) as unknown as CollectProjectsSuccessResult; From 0f1eaa2a1df56d3fcd81e590319aa8ebba10285a Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 1 Oct 2026 13:45:56 +0200 Subject: [PATCH 08/12] refactor(cli): leave the npx download confirmation to the package manager proxies The npm and Yarn 1 proxies now set npm_config_yes on remote runs, so the skills step no longer passes it, and pnpm, Yarn Berry and Bun no longer receive an npm-only variable. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 3 +-- code/core/src/cli/installSkills.ts | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index 1c747d2f9ed3..bade7b2717be 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -165,7 +165,7 @@ describe('installSkills', () => { useRemotePkg: true, cwd: PROJECT_ROOT, stdio: 'inherit', - env: { npm_config_yes: 'true' }, + env: {}, timeout: 120_000, }); expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'tag' }); @@ -222,7 +222,6 @@ describe('installSkills', () => { await installSkills({ packageManager, yes: true }); expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].env).toEqual({ - npm_config_yes: 'true', DISABLE_TELEMETRY: '1', }); }); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index a019e01866f6..fe557abed1c0 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -155,10 +155,7 @@ export async function installSkills({ 'universal', '--copy', ]; - const env: Record = { npm_config_yes: 'true' }; - if (prerelease(versions.storybook) || !isTelemetryModuleEnabled()) { - env.DISABLE_TELEMETRY = '1'; - } + const disableVercelTelemetry = prerelease(versions.storybook) || !isTelemetryModuleEnabled(); logger.log(CLI_COLORS.cta(packageManager.getRemoteRunCommand(args))); try { @@ -167,7 +164,7 @@ export async function installSkills({ useRemotePkg: true, cwd: projectRoot, stdio: 'inherit', - env, + env: disableVercelTelemetry ? { DISABLE_TELEMETRY: '1' } : {}, timeout: 120_000, }); } catch (error) { From 46916a8d3b33c0d223a0a939bd2ea0f2fe349bc2 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 1 Oct 2026 14:44:04 +0200 Subject: [PATCH 09/12] docs: say init and upgrade install the skills now that they do Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTRIBUTING/RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING/RELEASING.md b/CONTRIBUTING/RELEASING.md index bceb223c9cd5..e8a09ab70fce 100644 --- a/CONTRIBUTING/RELEASING.md +++ b/CONTRIBUTING/RELEASING.md @@ -234,7 +234,7 @@ The publish workflow runs in the "release" GitHub environment, which has the npm #### Syncing the skills -The canonical skills live in [`code/lib/claude-plugin/skills/`](../code/lib/claude-plugin/skills/). [`storybookjs/skills`](https://github.com/storybookjs/skills) makes them installable without the Claude Code plugin, with Vercel's `skills` CLI, and [skills.sh](https://skills.sh) counts those installs. `storybook init` and `storybook upgrade` are planned to install them from there, at the tag of the project's Storybook version. That repository is release output: nobody edits its files directly, and a skill change ships with the next Storybook release. The publish workflow writes to it, and after a failed sync a maintainer runs the same script by hand (see below). +The canonical skills live in [`code/lib/claude-plugin/skills/`](../code/lib/claude-plugin/skills/). [`storybookjs/skills`](https://github.com/storybookjs/skills) makes them installable without the Claude Code plugin, with Vercel's `skills` CLI, and [skills.sh](https://skills.sh) counts those installs. `storybook init` and `storybook upgrade` install them from there, at the tag of the project's Storybook version. That repository is release output: nobody edits its files directly, and a skill change ships with the next Storybook release. The publish workflow writes to it, and after a failed sync a maintainer runs the same script by hand (see below). Step 8 runs [`scripts/release/sync-skills.ts`](../scripts/release/sync-skills.ts), which: From 2dbc8795a42e9d358ec2248f076d509d69265336 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 1 Oct 2026 14:55:22 +0200 Subject: [PATCH 10/12] docs: list --skills and --no-skills in the CLI options Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api/cli-options.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api/cli-options.mdx b/docs/api/cli-options.mdx index 335fd9ab4de5..c80e7ee9e80f 100644 --- a/docs/api/cli-options.mdx +++ b/docs/api/cli-options.mdx @@ -127,6 +127,8 @@ Options include: | `-t`, `--type` | Defines the [framework](../configure/integration/frameworks.mdx) to use for your Storybook instance.
`storybook init --type solid` | | `-y`, `--yes` | Skips interactive prompts and automatically installs Storybook per specified version, including all features.
`storybook init --yes` | | `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs`, `test`, and `a11y`, space separated.
`storybook init --features docs test a11y` | +| `--skills` | Installs the official Storybook skills for AI agents into your project without asking.
`storybook init --skills` | +| `--no-skills` | Skips installing the official Storybook skills for AI agents. Storybook remembers this for the project and stops asking.
`storybook init --no-skills` | | `--package-manager` | Sets the package manager to use when installing Storybook.
Available package managers include `npm`, `yarn`, and `pnpm`.
`storybook init --package-manager pnpm` | | `-p`, `--parser` | Sets the [jscodeshift parser](https://github.com/facebook/jscodeshift#parser).
Available parsers include `babel`, `babylon`, `flow`, `ts`, and `tsx`.
`storybook init --parser tsx` | | `--debug` | Outputs more logs in the CLI to assist debugging.
`storybook init --debug` | @@ -195,6 +197,8 @@ Options include: | `--skip-automigrations` | Skips running automigrations entirely, only updating package versions and installing them.
`storybook upgrade --skip-automigrations` | | `-y`, `--yes` | Skips interactive prompts and automatically upgrades Storybook to the latest version. Migrations that require a deliberate opt-in, such as framework migrations and experimental feature flags, are not run; use `--features` to include them.
`storybook upgrade --yes` | | `--features ` | Preselects the automigrations that enable the listed experimental feature flags. Other detected automigrations are still offered as usual. Cannot be combined with `--skip-automigrations`.
`storybook upgrade --features experimentalReview,experimentalDocgenServer` | +| `--skills` | Installs the official Storybook skills for AI agents into your project without asking.
`storybook upgrade --skills` | +| `--no-skills` | Skips installing the official Storybook skills for AI agents. Storybook remembers this for the project and stops asking.
`storybook upgrade --no-skills` | | `-f`,`--force` | Force the upgrade, skipping autoblockers check.
`storybook upgrade --force` | | `--package-manager` | Sets the package manager to use when upgrading Storybook.
Available package managers include `npm`, `yarn`, and `pnpm`.
`storybook upgrade --package-manager pnpm` | | `--debug` | Outputs more logs in the CLI to assist debugging.
`storybook upgrade --debug` | @@ -422,6 +426,8 @@ Options include: | `-t`, `--type` | Defines the [framework](../configure/integration/frameworks.mdx) to use for your Storybook instance.
`create storybook --type solid` | | `-y`, `--yes` | Skips interactive prompts and automatically installs Storybook per specified version, including all features.
`create storybook --yes` | | `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs`, `test`, and `a11y`, space separated.
`create storybook --features docs test a11y` | +| `--skills` | Installs the official Storybook skills for AI agents into your project without asking.
`create storybook --skills` | +| `--no-skills` | Skips installing the official Storybook skills for AI agents. Storybook remembers this for the project and stops asking.
`create storybook --no-skills` | | `--package-manager` | Sets the package manager to use when installing Storybook.
Available package managers include `npm`, `yarn`, and `pnpm`.
`create storybook --package-manager pnpm` | | `-p`, `--parser` | Sets the [jscodeshift parser](https://github.com/facebook/jscodeshift#parser).
Available parsers include `babel`, `babylon`, `flow`, `ts`, and `tsx`.
`create storybook --parser tsx` | | `--debug` | Outputs more logs in the CLI to assist debugging.
`create storybook --debug` | From b9dfc97ff61c58c74ba6a0da25b1993597ea3edb Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 2 Oct 2026 13:28:23 +0200 Subject: [PATCH 11/12] refactor(cli): drop the tag lookup from the skills install Always install from the tag of the running Storybook version. The lookup cost half a second on every run for a tag that is only missing when the release sync failed. Also use the long --yes and --agent flags in the skills command and drop the agent list from the question. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 71 ++++--------------- code/core/src/cli/installSkills.ts | 37 ++-------- .../cli-storybook/src/upgrade.skills.test.ts | 3 +- .../src/services/TelemetryService.test.ts | 2 - 4 files changed, 19 insertions(+), 94 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index bade7b2717be..05bb3448889b 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { JsPackageManager } from 'storybook/internal/common'; -import { executeCommand, getProjectRoot, isCI } from 'storybook/internal/common'; +import { getProjectRoot, isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import { ExecaCommandFailedError, @@ -29,7 +29,6 @@ vi.mock(import('storybook/internal/common'), async (importOriginal) => { ...actual, getProjectRoot: vi.fn(), isCI: vi.fn(), - executeCommand: vi.fn(), get versions() { return { ...actual.versions, storybook: versionHolder.storybook }; }, @@ -42,8 +41,8 @@ const INSTALL_ARGS = (ref: string) => [ 'skills@latest', 'add', `storybookjs/skills#${ref}`, - '-y', - '-a', + '--yes', + '--agent', 'claude-code', 'universal', '--copy', @@ -61,9 +60,6 @@ const seedSettings = (agentSkills?: Record) => vol.fromNestedJSON({ [SETTINGS_PATH]: JSON.stringify({ version: 1, userSince: 1, agentSkills }), }); -const tagFound = () => - vi.mocked(executeCommand).mockResolvedValue({ stdout: 'abc\trefs/tags/v10.6.0\n' } as never); -const tagMissing = () => vi.mocked(executeCommand).mockResolvedValue({ stdout: '' } as never); describe('decideSkillsInstall', () => { const base = { isCI: false, isInteractive: true, yes: false, agent: false }; @@ -141,7 +137,6 @@ describe('installSkills', () => { vi.mocked(logger.debug).mockImplementation(() => {}); setIsTTY(process.stdin, true); setIsTTY(process.stdout, true); - tagFound(); }); afterEach(() => { @@ -157,7 +152,7 @@ describe('installSkills', () => { }); describe('the command', () => { - it('installs from the version tag when it exists', async () => { + it('installs from the tag of the running Storybook version', async () => { const result = await installSkills({ packageManager, yes: true }); expect(packageManager.runPackageCommand).toHaveBeenCalledWith({ @@ -168,48 +163,7 @@ describe('installSkills', () => { env: {}, timeout: 120_000, }); - expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'tag' }); - }); - - it('falls back to main for a release without a tag', async () => { - tagMissing(); - - const result = await installSkills({ packageManager, yes: true }); - - expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( - INSTALL_ARGS('main') - ); - expect(result.refType).toBe('branch'); - }); - - it('falls back to next for a prerelease without a tag', async () => { - versionHolder.storybook = '11.0.0-alpha.1'; - tagMissing(); - - await installSkills({ packageManager, yes: true }); - - expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( - INSTALL_ARGS('next') - ); - }); - - it('looks up the tag without a credential prompt and with a 10 second timeout', async () => { - await installSkills({ packageManager, yes: true }); - - expect(executeCommand).toHaveBeenCalledWith( - expect.objectContaining({ env: { GIT_TERMINAL_PROMPT: '0' }, timeout: 10_000 }) - ); - }); - - it('treats a failing tag lookup as a missing tag', async () => { - vi.mocked(executeCommand).mockRejectedValue(new Error('no network')); - - const result = await installSkills({ packageManager, yes: true }); - - expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].args).toEqual( - INSTALL_ARGS('main') - ); - expect(result).toEqual({ result: 'installed', source: 'yes', refType: 'branch' }); + expect(result).toEqual({ result: 'installed', source: 'yes' }); }); it.each([ @@ -231,7 +185,7 @@ describe('installSkills', () => { expect(logger.log).toHaveBeenCalledWith( expect.stringContaining( - 'npx skills@latest add storybookjs/skills#v10.6.0 -y -a claude-code universal --copy' + 'npx skills@latest add storybookjs/skills#v10.6.0 --yes --agent claude-code universal --copy' ) ); expect(logger.log).toHaveBeenCalledWith( @@ -253,7 +207,7 @@ describe('installSkills', () => { const result = await installSkills({ packageManager, yes: true }); - expect(result).toEqual({ result: 'failed', source: 'yes', refType: 'tag', exitCode: 1 }); + expect(result).toEqual({ result: 'failed', source: 'yes', exitCode: 1 }); expect(logger.warn).toHaveBeenCalledWith( 'Could not install the Storybook skills, continuing without them.' ); @@ -269,7 +223,7 @@ describe('installSkills', () => { const result = await installSkills({ packageManager }); expect(prompt.confirm).not.toHaveBeenCalled(); - expect(result).toEqual({ result: 'installed', source: 'settings', refType: 'tag' }); + expect(result).toEqual({ result: 'installed', source: 'settings' }); }); it('skips silently when the project remembered false', async () => { @@ -318,7 +272,7 @@ describe('installSkills', () => { const result = await installSkills({ packageManager, skillsFlag: true }); - expect(result).toEqual({ result: 'installed', source: 'flag', refType: 'tag' }); + expect(result).toEqual({ result: 'installed', source: 'flag' }); expect(settingsFile().agentSkills).toEqual({ [PROJECT_ROOT]: true }); }); @@ -347,13 +301,12 @@ describe('installSkills', () => { expect(prompt.confirm).toHaveBeenCalledWith( { - message: - 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + message: 'Install the official Storybook skills for AI agents into this project?', initialValue: true, }, expect.anything() ); - expect(result).toEqual({ result: 'installed', source: 'prompt', refType: 'tag' }); + expect(result).toEqual({ result: 'installed', source: 'prompt' }); }); it('declines on No, remembers false and spawns nothing', async () => { @@ -388,7 +341,7 @@ describe('installSkills', () => { const result = await installSkills({ packageManager }); expect(prompt.confirm).not.toHaveBeenCalled(); - expect(result).toEqual({ result: 'installed', source: 'default', refType: 'tag' }); + expect(result).toEqual({ result: 'installed', source: 'default' }); }); }); }); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index fe557abed1c0..8f6da26723db 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -1,5 +1,5 @@ import type { JsPackageManager } from 'storybook/internal/common'; -import { executeCommand, getProjectRoot, isCI, versions } from 'storybook/internal/common'; +import { getProjectRoot, isCI, versions } from 'storybook/internal/common'; import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; @@ -19,7 +19,6 @@ export type SkillsDecision = { export type SkillsInstallResult = { result: 'installed' | 'declined' | 'skipped' | 'failed'; source: SkillsSource; - refType?: 'tag' | 'branch'; exitCode?: number; }; @@ -58,27 +57,6 @@ export function decideSkillsInstall(input: { : { action: 'install', source: 'default' }; } -async function resolveSkillsRef( - storybookVersion: string -): Promise<{ ref: string; refType: 'tag' | 'branch' }> { - const tag = `v${storybookVersion}`; - try { - const { stdout } = await executeCommand({ - command: 'git', - args: ['ls-remote', '--tags', `https://github.com/${SKILLS_REPO}`, `refs/tags/${tag}`], - stdio: 'pipe', - env: { GIT_TERMINAL_PROMPT: '0' }, - timeout: 10_000, - }); - if (typeof stdout === 'string' && stdout.trim()) { - return { ref: tag, refType: 'tag' }; - } - } catch (error) { - logger.debug(error); - } - return { ref: prerelease(storybookVersion) ? 'next' : 'main', refType: 'branch' }; -} - /** * Install the official Storybook skills into the project root through Vercel's `skills` CLI, * remembering the answer per project in the global settings file. A failed install is reported in @@ -118,8 +96,7 @@ export async function installSkills({ let canceled = false; const accepted = await prompt.confirm( { - message: - 'Install the official Storybook skills for AI agents (Claude Code, Codex, Cursor) into this project?', + message: 'Install the official Storybook skills for AI agents into this project?', initialValue: true, }, { @@ -144,13 +121,12 @@ export async function installSkills({ return { result: 'skipped', source: decision.source }; } - const { ref, refType } = await resolveSkillsRef(versions.storybook); const args = [ 'skills@latest', 'add', - `${SKILLS_REPO}#${ref}`, - '-y', - '-a', + `${SKILLS_REPO}#v${versions.storybook}`, + '--yes', + '--agent', 'claude-code', 'universal', '--copy', @@ -175,7 +151,6 @@ export async function installSkills({ return { result: 'failed', source: decision.source, - refType, exitCode: typeof exitCode === 'number' ? exitCode : undefined, }; } @@ -184,5 +159,5 @@ export async function installSkills({ logger.log( `Skip this next time with --no-skills. Remove them with: ${packageManager.getRemoteRunCommand(['skills@latest', 'remove'])}` ); - return { result: 'installed', source: decision.source, refType }; + return { result: 'installed', source: decision.source }; } diff --git a/code/lib/cli-storybook/src/upgrade.skills.test.ts b/code/lib/cli-storybook/src/upgrade.skills.test.ts index 0e9779e0b7bc..8244a46ef635 100644 --- a/code/lib/cli-storybook/src/upgrade.skills.test.ts +++ b/code/lib/cli-storybook/src/upgrade.skills.test.ts @@ -78,7 +78,6 @@ describe('upgrade: the skills step', () => { vi.mocked(installSkills).mockResolvedValue({ result: 'installed', source: 'prompt', - refType: 'tag', }); }); @@ -104,7 +103,7 @@ describe('upgrade: the skills step', () => { expect(upgradeEvents).toHaveLength(2); for (const event of upgradeEvents) { expect(event).toMatchObject({ - skills: { result: 'installed', source: 'prompt', refType: 'tag' }, + skills: { result: 'installed', source: 'prompt' }, }); } }); diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts index 96d309317c69..00618a436b61 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -48,7 +48,6 @@ describe('TelemetryService', () => { await telemetryService.trackSkills({ result: 'failed', source: 'yes', - refType: 'branch', exitCode: 1, }); @@ -56,7 +55,6 @@ describe('TelemetryService', () => { step: 'skills', result: 'failed', source: 'yes', - refType: 'branch', exitCode: 1, }); }); From 133975f76339ae772f9338fee15a1ac612f633b0 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 2 Oct 2026 13:39:51 +0200 Subject: [PATCH 12/12] fix(cli): keep Vercel's telemetry on for prerelease skills installs Only turn it off when Storybook telemetry is off. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/cli/installSkills.test.ts | 15 +++------------ code/core/src/cli/installSkills.ts | 5 +---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/code/core/src/cli/installSkills.test.ts b/code/core/src/cli/installSkills.test.ts index 05bb3448889b..c452a991ba11 100644 --- a/code/core/src/cli/installSkills.test.ts +++ b/code/core/src/cli/installSkills.test.ts @@ -18,8 +18,6 @@ import { vol } from 'memfs'; import { _clearGlobalSettings } from './globalSettings.ts'; import { decideSkillsInstall, installSkills } from './installSkills.ts'; -const versionHolder = vi.hoisted(() => ({ storybook: '10.6.0' })); - vi.mock('node:fs/promises', { spy: true }); vi.mock('storybook/internal/node-logger', { spy: true }); vi.mock('storybook/internal/telemetry', { spy: true }); @@ -29,9 +27,7 @@ vi.mock(import('storybook/internal/common'), async (importOriginal) => { ...actual, getProjectRoot: vi.fn(), isCI: vi.fn(), - get versions() { - return { ...actual.versions, storybook: versionHolder.storybook }; - }, + versions: { ...actual.versions, storybook: '10.6.0' }, }; }); @@ -121,7 +117,6 @@ describe('installSkills', () => { ); vol.reset(); _clearGlobalSettings(); - versionHolder.storybook = '10.6.0'; packageManager = { runPackageCommand: vi.fn().mockResolvedValue(undefined), @@ -166,12 +161,8 @@ describe('installSkills', () => { expect(result).toEqual({ result: 'installed', source: 'yes' }); }); - it.each([ - ['a prerelease', '11.0.0-alpha.1', true], - ['disabled Storybook telemetry', '10.6.0', false], - ])('disables Vercel telemetry for %s', async (_, version, telemetryEnabled) => { - versionHolder.storybook = version; - vi.mocked(isTelemetryModuleEnabled).mockReturnValue(telemetryEnabled); + it('disables Vercel telemetry when Storybook telemetry is disabled', async () => { + vi.mocked(isTelemetryModuleEnabled).mockReturnValue(false); await installSkills({ packageManager, yes: true }); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts index 8f6da26723db..5710f0d292e6 100644 --- a/code/core/src/cli/installSkills.ts +++ b/code/core/src/cli/installSkills.ts @@ -3,8 +3,6 @@ import { getProjectRoot, isCI, versions } from 'storybook/internal/common'; import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; import { isTelemetryModuleEnabled } from 'storybook/internal/telemetry'; -import { prerelease } from 'semver'; - import { globalSettings } from './globalSettings.ts'; const SKILLS_REPO = 'storybookjs/skills'; @@ -131,7 +129,6 @@ export async function installSkills({ 'universal', '--copy', ]; - const disableVercelTelemetry = prerelease(versions.storybook) || !isTelemetryModuleEnabled(); logger.log(CLI_COLORS.cta(packageManager.getRemoteRunCommand(args))); try { @@ -140,7 +137,7 @@ export async function installSkills({ useRemotePkg: true, cwd: projectRoot, stdio: 'inherit', - env: disableVercelTelemetry ? { DISABLE_TELEMETRY: '1' } : {}, + env: isTelemetryModuleEnabled() ? {} : { DISABLE_TELEMETRY: '1' }, timeout: 120_000, }); } catch (error) {