Skip to content
66 changes: 61 additions & 5 deletions code/core/src/cli/ai/mcp/intercepts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import { describe, expect, it } from 'vitest';
import { getInterceptMarkdown } from './intercepts.ts';
import type { StorybookInstanceRecord } from './types.ts';

const record = (cwd: string, url: string): StorybookInstanceRecord => ({
const record = (
cwd: string,
url: string,
overrides: Partial<StorybookInstanceRecord> = {}
): StorybookInstanceRecord => ({
schemaVersion: 1,
instanceId: 'i-1',
pid: 1,
cwd,
url,
port: 6006,
mcp: { status: 'ready', endpoint: '/mcp' },
...overrides,
});

describe('getInterceptMarkdown', () => {
Expand All @@ -20,16 +25,67 @@ describe('getInterceptMarkdown', () => {
expect(markdown).toContain('storybook dev');
});

it('no-instance with candidates lists the running cwds', () => {
it('no-instance with candidates lists the running instances and a copyable --cwd retry', () => {
const markdown = getInterceptMarkdown('no-instance', {
records: [record('/projects/foo', 'http://localhost:6006')],
});
expect(markdown).toContain('Running Storybooks:');
expect(markdown).toContain('- `/projects/foo` (http://localhost:6006)');
expect(markdown).toContain('--cwd');
expect(markdown).toContain('- cwd `/projects/foo` (http://localhost:6006)');
expect(markdown).toContain('- `storybook ai --cwd /projects/foo <command> [args...]`');
expect(markdown).toContain('BEFORE the command name');
expect(markdown).not.toContain('storybook ai --config-dir');
});

it('port-mismatch lists the ports running at the cwd', () => {
it('no-instance with candidates includes the recorded config dir and a --config-dir retry', () => {
const markdown = getInterceptMarkdown('no-instance', {
records: [
record('/repo', 'http://localhost:6006', {
configDir: '/repo/packages/ui/.storybook',
}),
],
});
expect(markdown).toContain(
'- cwd `/repo`, config dir `/repo/packages/ui/.storybook` (http://localhost:6006)'
);
expect(markdown).toContain(
'- `storybook ai --config-dir /repo/packages/ui/.storybook <command> [args...]`'
);
// A bare `--cwd /repo` retry would fail metadata loading when the config is not at
// /repo/.storybook, so it must not be offered for records with a configDir.
expect(markdown).not.toContain('storybook ai --cwd');
});

it('no-instance quotes paths containing whitespace in retry examples', () => {
const markdown = getInterceptMarkdown('no-instance', {
records: [
record('/Users/John Smith/proj', 'http://localhost:6006'),
record('/repo', 'http://localhost:6007', {
instanceId: 'i-2',
pid: 2,
configDir: '/repo/my packages/ui/.storybook',
}),
],
});
expect(markdown).toContain(
'- `storybook ai --cwd "/Users/John Smith/proj" <command> [args...]`'
);
expect(markdown).toContain(
'- `storybook ai --config-dir "/repo/my packages/ui/.storybook" <command> [args...]`'
);
});

it('no-instance deduplicates retry examples across records sharing a cwd', () => {
const markdown = getInterceptMarkdown('no-instance', {
records: [
record('/projects/foo', 'http://localhost:6006'),
record('/projects/foo', 'http://localhost:6007', { instanceId: 'i-2', pid: 2 }),
],
});
const occurrences = markdown.split('`storybook ai --cwd /projects/foo').length - 1;
expect(occurrences).toBe(1);
});

it('port-mismatch lists the ports running for the project', () => {
const markdown = getInterceptMarkdown('port-mismatch', {
port: 9999,
records: [record('/projects/foo', 'http://localhost:6006')],
Expand Down
33 changes: 28 additions & 5 deletions code/core/src/cli/ai/mcp/intercepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,39 @@ import type { InterceptReason, StorybookInstanceRecord } from './types.ts';
*/
const NO_INSTANCE_EMPTY = `Storybook is not running at this cwd. Start \`storybook dev\` from the project's cwd and retry the command.`;

const buildNoInstanceWithCandidates = (records: StorybookInstanceRecord[]) =>
`No Storybook is running at this cwd. Either start Storybook from the project's cwd, or retry with \`--cwd\` set to one of the running cwds below.
const quotePath = (path: string) => (/\s/.test(path) ? `"${path}"` : path);

const buildNoInstanceWithCandidates = (records: StorybookInstanceRecord[]) => {
const instances = records.map((r) => {
const configDir = r.configDir ? `, config dir \`${r.configDir}\`` : '';
return `- cwd \`${r.cwd}\`${configDir} (${r.url})`;
});
// One concrete invocation per instance, built from its recorded values so the agent can copy it
// verbatim. Prefer `--config-dir` when recorded: a bare `--cwd` retry fails for instances whose
// config is not at `<cwd>/.storybook`, because command metadata loads from there first.
const examples = [
...new Set(
records.map((r) =>
r.configDir
? `- \`storybook ai --config-dir ${quotePath(r.configDir)} <command> [args...]\``
: `- \`storybook ai --cwd ${quotePath(r.cwd)} <command> [args...]\``
)
Comment thread
kasperpeulen marked this conversation as resolved.
),
];

return `No running Storybook matches this project (neither its cwd nor its Storybook config dir). Either start \`storybook dev\` from this project, or retry with \`--cwd\` or \`--config-dir\` pointing at one of the running Storybooks below. Both flags must be placed BEFORE the command name.

Running Storybooks:
${records.map((r) => `- \`${r.cwd}\` (${r.url})`).join('\n')}`;
${instances.join('\n')}

Retry examples (replace \`<command>\` with the command you ran):
${examples.join('\n')}`;
};

const buildPortMismatch = (port: number | undefined, records: StorybookInstanceRecord[]) =>
`Storybook is running at this cwd, but not on port \`${port ?? 'unknown'}\`. Retry with one of the running ports below, or omit \`--port\` to route by cwd alone.
`Storybook is running for this project, but not on port \`${port ?? 'unknown'}\`. Retry with one of the running ports below, or omit \`--port\` to route by project alone.

Running Storybooks at this cwd:
Running Storybooks for this project:
${records.map((r) => `- port \`${r.port}\` (${r.url}, status: \`${r.mcp.status}\`)`).join('\n')}`;

const ADDON_MISSING = `Storybook is running but does not provide these commands. The \`@storybook/addon-mcp\` addon is missing.
Expand Down
2 changes: 1 addition & 1 deletion code/core/src/cli/ai/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function isAiCliFeatureEnabled(env: NodeJS.ProcessEnv = process.env): boo

const CWD_DESCRIPTION = 'Project directory of the target Storybook; place before the command name';
const CONFIG_DIR_DESCRIPTION =
'Storybook config directory for help and local commands; place before the command name';
'Storybook config directory of the target Storybook; place before the command name';
const PORT_DESCRIPTION =
'Port of the target Storybook for runtime commands; place before the command name';

Expand Down
9 changes: 9 additions & 0 deletions code/core/src/cli/ai/mcp/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ describe('readRegistry', () => {
await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([minimal]);
});

it('accepts records with an optional configDir (written by Storybook >= 10.5)', async () => {
const withConfigDir = { ...aliveRecord, configDir: '/projects/alive/packages/ui/.storybook' };
vol.fromNestedJSON({
[REGISTRY_DIR]: { 'config-dir.json': JSON.stringify(withConfigDir) },
});

await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([withConfigDir]);
});

it('accepts records with optional agent provenance', async () => {
const agentRecord = { ...aliveRecord, agent: 'claude-preview' };
vol.fromNestedJSON({ [REGISTRY_DIR]: { 'agent.json': JSON.stringify(agentRecord) } });
Expand Down
Loading
Loading