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: 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..c452a991ba11 --- /dev/null +++ b/code/core/src/cli/installSkills.test.ts @@ -0,0 +1,338 @@ +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 { getProjectRoot, isCI } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { + ExecaCommandFailedError, + PackageInstallFailedError, +} 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'; + +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(), + versions: { ...actual.versions, storybook: '10.6.0' }, + }; +}); + +const PROJECT_ROOT = '/test/project'; +const SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json'); +const INSTALL_ARGS = (ref: string) => [ + 'skills@latest', + 'add', + `storybookjs/skills#${ref}`, + '--yes', + '--agent', + 'claude-code', + 'universal', + '--copy', +]; + +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) => + vol.fromNestedJSON({ + [SETTINGS_PATH]: JSON.stringify({ version: 1, userSince: 1, agentSkills }), + }); + +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(); + + 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(() => {}); + setIsTTY(process.stdin, true); + setIsTTY(process.stdout, true); + }); + + afterEach(() => { + terminalStreams.forEach((stream, index) => { + const original = originalIsTTY[index]; + if (original) { + Object.defineProperty(stream, 'isTTY', original); + } else { + delete (stream as { isTTY?: boolean }).isTTY; + } + }); + vol.reset(); + }); + + describe('the command', () => { + it('installs from the tag of the running Storybook version', 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: {}, + timeout: 120_000, + }); + expect(result).toEqual({ result: 'installed', source: 'yes' }); + }); + + it('disables Vercel telemetry when Storybook telemetry is disabled', async () => { + vi.mocked(isTelemetryModuleEnabled).mockReturnValue(false); + + await installSkills({ packageManager, yes: true }); + + expect(vi.mocked(packageManager.runPackageCommand).mock.calls[0][0].env).toEqual({ + 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 --yes --agent 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.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', 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' }); + }); + + 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, 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 () => { + 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' }); + 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 into this project?', + initialValue: true, + }, + expect.anything() + ); + expect(result).toEqual({ result: 'installed', source: 'prompt' }); + }); + + 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('declines 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.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 }); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'installed', source: 'default' }); + }); + }); +}); diff --git a/code/core/src/cli/installSkills.ts b/code/core/src/cli/installSkills.ts new file mode 100644 index 000000000000..5710f0d292e6 --- /dev/null +++ b/code/core/src/cli/installSkills.ts @@ -0,0 +1,160 @@ +import type { JsPackageManager } 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'; + +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; + 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' }; +} + +/** + * 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, + 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 && !!process.stdin.isTTY, + yes, + agent, + remembered: settings.value.agentSkills?.[projectRoot], + }); + + if (decision.action === 'ask') { + let canceled = false; + const accepted = await prompt.confirm( + { + message: 'Install the official Storybook skills for AI agents 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 }; + } + } + + if (decision.action === 'skip') { + if (decision.source === 'flag') { + await remember(false); + } + return { result: 'skipped', source: decision.source }; + } + + const args = [ + 'skills@latest', + 'add', + `${SKILLS_REPO}#v${versions.storybook}`, + '--yes', + '--agent', + 'claude-code', + 'universal', + '--copy', + ]; + + logger.log(CLI_COLORS.cta(packageManager.getRemoteRunCommand(args))); + try { + await packageManager.runPackageCommand({ + args, + useRemotePkg: true, + cwd: projectRoot, + stdio: 'inherit', + env: isTelemetryModuleEnabled() ? {} : { DISABLE_TELEMETRY: '1' }, + timeout: 120_000, + }); + } catch (error) { + logger.warn('Could not install the Storybook skills, continuing without them.'); + logger.debug(error); + // 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', + source: decision.source, + exitCode: typeof exitCode === 'number' ? 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 }; +} 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 2e9fd9892edc..b752b47dea33 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({ useRemotePkg = false, ...options 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 eb8b6a634563..15d44d609f0a 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -129,6 +129,11 @@ export class PNPMProxy extends JsPackageManager { return `pnpm exec ${args.join(' ')}`; } + getRemoteRunCommand(args: string[]): string { + const allowBuild = this.#pnpmGte(PNPM_ALLOW_BUILD_DLX_MIN) ? '--allow-build=esbuild ' : ''; + return `pnpm ${allowBuild}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 ab895b439850..4ec541e49ebf 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/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/cli-storybook/src/upgrade.skills.test.ts b/code/lib/cli-storybook/src/upgrade.skills.test.ts new file mode 100644 index 000000000000..8244a46ef635 --- /dev/null +++ b/code/lib/cli-storybook/src/upgrade.skills.test.ts @@ -0,0 +1,116 @@ +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: async () => {}, + 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: async () => {}, + } 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', + }); + }); + + 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' }, + }); + } + }); + + 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..7c174eb99a8e 100644 --- a/code/lib/create-storybook/src/generators/types.ts +++ b/code/lib/create-storybook/src/generators/types.ts @@ -145,6 +145,10 @@ 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; + /** 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 7c370be35751..4d575b4b56a6 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,19 @@ export async function doInitiate(options: CommandOptions): Promise< options, }); - // Step 8: Print final summary + // Step 8: Install the official Storybook skills for AI agents + 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); if (configDir && isAiSetupAvailable) { // Persist init-time AI opt-in/opt-out so the dev server can gate AI-related UI @@ -193,7 +205,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..00618a436b61 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -44,6 +44,21 @@ describe('TelemetryService', () => { }); }); + it('should track the skills step', async () => { + await telemetryService.trackSkills({ + result: 'failed', + source: 'yes', + exitCode: 1, + }); + + expect(telemetry).toHaveBeenCalledWith('init-step', { + step: 'skills', + result: 'failed', + source: 'yes', + 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..fccfee4fc732 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -1,4 +1,4 @@ -import type { ProjectType } from 'storybook/internal/cli'; +import type { ProjectType, SkillsInstallResult } from 'storybook/internal/cli'; import { getProcessAncestry, getStorybookVersionSpecifierFromAncestry, @@ -47,6 +47,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: { 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` |