diff --git a/code/core/src/cli/ai/mcp/intercepts.test.ts b/code/core/src/cli/ai/mcp/intercepts.test.ts index df444e6efcd6..a0c772b9237f 100644 --- a/code/core/src/cli/ai/mcp/intercepts.test.ts +++ b/code/core/src/cli/ai/mcp/intercepts.test.ts @@ -3,7 +3,11 @@ 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 => ({ schemaVersion: 1, instanceId: 'i-1', pid: 1, @@ -11,6 +15,7 @@ const record = (cwd: string, url: string): StorybookInstanceRecord => ({ url, port: 6006, mcp: { status: 'ready', endpoint: '/mcp' }, + ...overrides, }); describe('getInterceptMarkdown', () => { @@ -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 [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 [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" [args...]`' + ); + expect(markdown).toContain( + '- `storybook ai --config-dir "/repo/my packages/ui/.storybook" [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')], diff --git a/code/core/src/cli/ai/mcp/intercepts.ts b/code/core/src/cli/ai/mcp/intercepts.ts index 3390445461c0..4084eaa7278c 100644 --- a/code/core/src/cli/ai/mcp/intercepts.ts +++ b/code/core/src/cli/ai/mcp/intercepts.ts @@ -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 `/.storybook`, because command metadata loads from there first. + const examples = [ + ...new Set( + records.map((r) => + r.configDir + ? `- \`storybook ai --config-dir ${quotePath(r.configDir)} [args...]\`` + : `- \`storybook ai --cwd ${quotePath(r.cwd)} [args...]\`` + ) + ), + ]; + + 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 \`\` 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. diff --git a/code/core/src/cli/ai/mcp/register.ts b/code/core/src/cli/ai/mcp/register.ts index ab2935f4c1b7..19704cf005d2 100644 --- a/code/core/src/cli/ai/mcp/register.ts +++ b/code/core/src/cli/ai/mcp/register.ts @@ -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'; diff --git a/code/core/src/cli/ai/mcp/registry.test.ts b/code/core/src/cli/ai/mcp/registry.test.ts index 3e7e742c6c0e..7026f9f739e4 100644 --- a/code/core/src/cli/ai/mcp/registry.test.ts +++ b/code/core/src/cli/ai/mcp/registry.test.ts @@ -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) } }); diff --git a/code/core/src/cli/ai/mcp/resolve-instance.test.ts b/code/core/src/cli/ai/mcp/resolve-instance.test.ts index 10d619549ba2..2f591d991c87 100644 --- a/code/core/src/cli/ai/mcp/resolve-instance.test.ts +++ b/code/core/src/cli/ai/mcp/resolve-instance.test.ts @@ -31,14 +31,14 @@ function record( describe('resolveInstance', () => { it('returns no-instance with empty candidates when registry is empty', () => { - const result = resolveInstance([], '/Users/x/projects/foo'); + const result = resolveInstance([], { cwd: '/Users/x/projects/foo' }); expect(result).toEqual({ kind: 'intercept', reason: 'no-instance', records: [], matches: [] }); }); it('returns no-instance with candidates when no record cwd matches', () => { const a = record('/Users/x/projects/foo'); const b = record('/Users/x/projects/bar'); - const result = resolveInstance([a, b], '/Users/x/projects/baz'); + const result = resolveInstance([a, b], { cwd: '/Users/x/projects/baz' }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('no-instance'); @@ -48,19 +48,19 @@ describe('resolveInstance', () => { it('matches a record by exact normalized cwd', () => { const r = record('/Users/x/projects/foo'); - const result = resolveInstance([r], '/Users/x/projects/foo'); + const result = resolveInstance([r], { cwd: '/Users/x/projects/foo' }); expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); }); it('normalizes trailing slashes and dot segments before matching', () => { const r = record('/Users/x/projects/foo'); - const result = resolveInstance([r], '/Users/x/projects/foo/./'); + const result = resolveInstance([r], { cwd: '/Users/x/projects/foo/./' }); expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); }); it('does NOT match a child path of a record cwd (exact only)', () => { const r = record('/Users/x/projects/foo'); - const result = resolveInstance([r], '/Users/x/projects/foo/src/Button.tsx'); + const result = resolveInstance([r], { cwd: '/Users/x/projects/foo/src/Button.tsx' }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('no-instance'); @@ -69,17 +69,148 @@ describe('resolveInstance', () => { it('does NOT match a sibling string prefix', () => { const r = record('/Users/x/projects/foo'); - const result = resolveInstance([r], '/Users/x/projects/foobar'); + const result = resolveInstance([r], { cwd: '/Users/x/projects/foobar' }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('no-instance'); } }); + it('matches a record by configDir when the cwds differ (monorepo root dev, leaf CLI)', () => { + const r = record('/repo', 'ready', { configDir: '/repo/packages/ui/.storybook' }); + const result = resolveInstance([r], { + cwd: '/repo/packages/ui', + configDir: '/repo/packages/ui/.storybook', + }); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('matches a record by cwd when the DEFAULTED configDir differs', () => { + const r = record('/repo/packages/ui', 'ready', { + configDir: '/repo/packages/ui/.storybook', + }); + const result = resolveInstance([r], { + cwd: '/repo/packages/ui', + configDir: '/repo/.storybook', + }); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('does NOT fall back to cwd matching when an EXPLICIT configDir differs', () => { + const r = record('/repo', 'ready', { configDir: '/repo/.storybook' }); + const result = resolveInstance([r], { + cwd: '/repo', + configDir: '/repo/packages/ui/.storybook', + configDirExplicit: true, + }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + expect(result.records).toEqual([r]); + } + }); + + it('selects by an EXPLICIT configDir among same-cwd instances serving different configs', () => { + const packageA = record('/repo', 'ready', { + configDir: '/repo/packages/a/.storybook', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerPackageB = record('/repo', 'ready', { + configDir: '/repo/packages/b/.storybook', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([packageA, newerPackageB], { + cwd: '/repo', + configDir: '/repo/packages/a/.storybook', + configDirExplicit: true, + }); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(packageA); + expect(result.matches).toEqual([packageA]); + } + }); + + it('cannot select records without a recorded configDir via an EXPLICIT configDir', () => { + const legacy = record('/repo/packages/ui'); + const result = resolveInstance([legacy], { + cwd: '/repo/packages/ui', + configDir: '/repo/packages/ui/.storybook', + configDirExplicit: true, + }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('normalizes trailing slashes and dot segments in configDirs before matching', () => { + const r = record('/repo', 'ready', { configDir: '/repo/packages/ui/.storybook' }); + const result = resolveInstance([r], { + cwd: '/elsewhere', + configDir: '/repo/packages/ui/./.storybook/', + }); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('does NOT match by configDir prefix (exact only)', () => { + const r = record('/repo', 'ready', { configDir: '/repo/packages/ui/.storybook' }); + const result = resolveInstance([r], { + cwd: '/elsewhere', + configDir: '/repo/packages/ui/.storybook/subdir', + }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('does NOT match records without a recorded configDir by configDir (older Storybooks)', () => { + const r = record('/repo'); + const result = resolveInstance([r], { + cwd: '/repo/packages/ui', + configDir: '/repo/packages/ui/.storybook', + }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('does NOT match a recorded configDir when the target has no configDir', () => { + const r = record('/repo', 'ready', { configDir: '/repo/packages/ui/.storybook' }); + const result = resolveInstance([r], { cwd: '/repo/packages/ui' }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('buckets cwd-matched and configDir-matched records together', () => { + const byCwd = record('/repo/packages/ui', 'ready', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const byConfigDir = record('/repo', 'ready', { + configDir: '/repo/packages/ui/.storybook', + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([byCwd, byConfigDir], { + cwd: '/repo/packages/ui', + configDir: '/repo/packages/ui/.storybook', + }); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(byConfigDir); + expect(result.matches).toEqual([byConfigDir, byCwd]); + } + }); + it('tie-breaks on lowest pid when 2+ records share the cwd and none carry a startedAt', () => { const a = record('/Users/x/projects/foo', 'ready', { pid: 200 }); const b = record('/Users/x/projects/foo', 'ready', { pid: 100 }); - const result = resolveInstance([a, b], '/Users/x/projects/foo'); + const result = resolveInstance([a, b], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(b); @@ -96,7 +227,7 @@ describe('resolveInstance', () => { pid: 200, startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([older, newer], '/Users/x/projects/foo'); + const result = resolveInstance([older, newer], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(newer); @@ -110,7 +241,7 @@ describe('resolveInstance', () => { pid: 200, startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([noStamp, stamped], '/Users/x/projects/foo'); + const result = resolveInstance([noStamp, stamped], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(stamped); @@ -126,7 +257,7 @@ describe('resolveInstance', () => { pid: 200, startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([ready, newerStarting], '/Users/x/projects/foo'); + const result = resolveInstance([ready, newerStarting], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(ready); @@ -142,7 +273,7 @@ describe('resolveInstance', () => { pid: 200, startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([olderError, newerStarting], '/Users/x/projects/foo'); + const result = resolveInstance([olderError, newerStarting], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('mcp-starting'); @@ -152,7 +283,7 @@ describe('resolveInstance', () => { it('prefers a ready record over non-ready ones when multiple records share the cwd', () => { const starting = record('/Users/x/projects/foo', 'starting', { pid: 100 }); const ready = record('/Users/x/projects/foo', 'ready', { pid: 200 }); - const result = resolveInstance([starting, ready], '/Users/x/projects/foo'); + const result = resolveInstance([starting, ready], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(ready); @@ -163,25 +294,25 @@ describe('resolveInstance', () => { it('falls back to dispatching the lowest-pid status when no record at the cwd is ready', () => { const a = record('/Users/x/projects/foo', 'starting', { pid: 200 }); const b = record('/Users/x/projects/foo', 'error', { pid: 100 }); - const result = resolveInstance([a, b], '/Users/x/projects/foo'); + const result = resolveInstance([a, b], { cwd: '/Users/x/projects/foo' }); expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [b, a] }); }); it('dispatches mcp.status=starting as mcp-starting intercept', () => { const r = record('/p', 'starting'); - const result = resolveInstance([r], '/p'); + const result = resolveInstance([r], { cwd: '/p' }); expect(result).toEqual({ kind: 'intercept', reason: 'mcp-starting', matches: [r] }); }); it('dispatches mcp.status=not-installed as addon-missing intercept', () => { const r = record('/p', 'not-installed'); - const result = resolveInstance([r], '/p'); + const result = resolveInstance([r], { cwd: '/p' }); expect(result).toEqual({ kind: 'intercept', reason: 'addon-missing', matches: [r] }); }); it('dispatches mcp.status=error as mcp-error intercept', () => { const r = record('/p', 'error'); - const result = resolveInstance([r], '/p'); + const result = resolveInstance([r], { cwd: '/p' }); expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [r] }); }); @@ -192,7 +323,11 @@ describe('resolveInstance', () => { port: 6006, }); const b = record('/Users/x/projects/foo', 'ready', { agent: 'codex', pid: 200, port: 6007 }); - const result = resolveInstance([a, b], '/Users/x/projects/foo', 6007, 'claude'); + const result = resolveInstance([a, b], { + cwd: '/Users/x/projects/foo', + port: 6007, + agent: 'claude', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(b); @@ -213,12 +348,10 @@ describe('resolveInstance', () => { agent: 'codex', startedAt: '2026-06-09T12:00:00.000Z', }); - const result = resolveInstance( - [genericClaude, claudePreview, newerCodex], - '/Users/x/projects/foo', - undefined, - 'claude' - ); + const result = resolveInstance([genericClaude, claudePreview, newerCodex], { + cwd: '/Users/x/projects/foo', + agent: 'claude', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -236,12 +369,10 @@ describe('resolveInstance', () => { agent: 'codex', startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance( - [genericClaude, newerCodex], - '/Users/x/projects/foo', - undefined, - 'claude' - ); + const result = resolveInstance([genericClaude, newerCodex], { + cwd: '/Users/x/projects/foo', + agent: 'claude', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -259,12 +390,10 @@ describe('resolveInstance', () => { agent: 'codex', startedAt: '2026-06-09T10:00:00.000Z', }); - const result = resolveInstance( - [startingPreview, readyCodex], - '/Users/x/projects/foo', - undefined, - 'claude' - ); + const result = resolveInstance([startingPreview, readyCodex], { + cwd: '/Users/x/projects/foo', + agent: 'claude', + }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { @@ -282,12 +411,10 @@ describe('resolveInstance', () => { agent: 'claude', startedAt: '2026-06-09T10:00:00.000Z', }); - const result = resolveInstance( - [erroredPreview, readyClaude], - '/Users/x/projects/foo', - undefined, - 'claude' - ); + const result = resolveInstance([erroredPreview, readyClaude], { + cwd: '/Users/x/projects/foo', + agent: 'claude', + }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { @@ -305,12 +432,10 @@ describe('resolveInstance', () => { agent: 'cursor', startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance( - [codex, newerCursor], - '/Users/x/projects/foo', - undefined, - 'codex' - ); + const result = resolveInstance([codex, newerCursor], { + cwd: '/Users/x/projects/foo', + agent: 'codex', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -332,12 +457,10 @@ describe('resolveInstance', () => { agent: 'codex', startedAt: '2026-06-09T12:00:00.000Z', }); - const result = resolveInstance( - [olderPreview, newerPreview, newestCodex], - '/Users/x/projects/foo', - undefined, - 'claude' - ); + const result = resolveInstance([olderPreview, newerPreview, newestCodex], { + cwd: '/Users/x/projects/foo', + agent: 'claude', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -354,7 +477,10 @@ describe('resolveInstance', () => { agent: 'cursor', startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([older, newer], '/Users/x/projects/foo', undefined, 'codex'); + const result = resolveInstance([older, newer], { + cwd: '/Users/x/projects/foo', + agent: 'codex', + }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -372,7 +498,7 @@ describe('resolveInstance', () => { agent: 'codex', startedAt: '2026-06-09T11:00:00.000Z', }); - const result = resolveInstance([olderPreview, newerCodex], '/Users/x/projects/foo'); + const result = resolveInstance([olderPreview, newerCodex], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { @@ -384,7 +510,7 @@ describe('resolveInstance', () => { it('ignores port when it is not supplied (routes by cwd alone)', () => { const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); - const result = resolveInstance([a, b], '/Users/x/projects/foo'); + const result = resolveInstance([a, b], { cwd: '/Users/x/projects/foo' }); expect(result.kind).toBe('instance'); if (result.kind === 'instance') { expect(result.record).toBe(a); @@ -395,7 +521,7 @@ describe('resolveInstance', () => { it('returns port-mismatch with the cwd instances as candidates when cwd matches but no instance is on the port', () => { const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); - const result = resolveInstance([a, b], '/Users/x/projects/foo', 9999); + const result = resolveInstance([a, b], { cwd: '/Users/x/projects/foo', port: 9999 }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('port-mismatch'); @@ -404,9 +530,26 @@ describe('resolveInstance', () => { } }); + it('returns port-mismatch when the configDir matches but no instance is on the port', () => { + const r = record('/repo', 'ready', { + configDir: '/repo/packages/ui/.storybook', + port: 6006, + }); + const result = resolveInstance([r], { + cwd: '/repo/packages/ui', + configDir: '/repo/packages/ui/.storybook', + port: 9999, + }); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('port-mismatch'); + expect(result.records).toEqual([r]); + } + }); + it('returns no-instance (not port-mismatch) when the cwd itself does not match', () => { const a = record('/Users/x/projects/foo', 'ready', { port: 6006 }); - const result = resolveInstance([a], '/Users/x/projects/bar', 6006); + const result = resolveInstance([a], { cwd: '/Users/x/projects/bar', port: 6006 }); expect(result.kind).toBe('intercept'); if (result.kind === 'intercept') { expect(result.reason).toBe('no-instance'); diff --git a/code/core/src/cli/ai/mcp/resolve-instance.ts b/code/core/src/cli/ai/mcp/resolve-instance.ts index dd038748a72e..9b3774e60e45 100644 --- a/code/core/src/cli/ai/mcp/resolve-instance.ts +++ b/code/core/src/cli/ai/mcp/resolve-instance.ts @@ -19,16 +19,44 @@ export type ResolveResult = matches: StorybookInstanceRecord[]; }; +export type ResolveTarget = { + /** Normalised before matching; usually the CLI's `--cwd` or `process.cwd()`. */ + cwd: string; + /** + * Resolved config directory the CLI is targeting (from `--config-dir`, or the `.storybook` + * default under `cwd`). Matched against the `configDir` recorded by `storybook dev`. + */ + configDir?: string; + /** + * True when `configDir` came from an explicit `--config-dir` flag rather than the `.storybook` + * default under `cwd`. An explicit config dir expresses precise intent, so matching is then + * restricted to records with that exact configDir: a same-cwd instance serving a different + * config must not win over the flag. + */ + configDirExplicit?: boolean; + /** Port of the target Storybook, to address one specific instance among the matches. */ + port?: number; + /** The invoking agent (std-env name), used to pick among competing matches. */ + agent?: string; +}; + /** - * Pick the Storybook instance whose cwd exactly matches `targetCwd` after normalisation. Per - * milestone 2 of storybookjs/storybook#34826: matching is exact-normalized, with no longest-prefix - * or fallback behaviour. + * Pick the Storybook instance that matches the target project. With an explicit `--config-dir` + * (`configDirExplicit`), only records whose recorded `configDir` equals `target.configDir` match. + * Otherwise a record matches when its recorded `cwd` equals `target.cwd` OR its recorded + * `configDir` equals the defaulted `target.configDir`. All comparisons are exact-normalized with + * no longest-prefix or fallback behaviour (milestone 2 of storybookjs/storybook#34826). The + * configDir key exists for monorepos (storybookjs/storybook#35359): a dev server started at the + * repo root with `-c packages/ui/.storybook` must be found by a CLI run from `packages/ui`, and + * vice versa. Records from older Storybooks carry no `configDir` and can only match by cwd — so + * an explicit `--config-dir` cannot select them, and the no-instance guidance offers their + * `--cwd` instead. * - * When `targetPort` is supplied (e.g. an agent that launched Storybook on a known port and wants - * to address that exact instance), it further constrains the cwd matches: an instance must match - * BOTH cwd and port. If the cwd matches but no instance there is on `targetPort`, a - * `port-mismatch` intercept is returned with the cwd's instances as candidates so callers can - * surface the running ports. + * When `target.port` is supplied (e.g. an agent that launched Storybook on a known port and wants + * to address that exact instance), it further constrains the project matches: an instance must + * match BOTH the project and the port. If the project matches but no instance there is on the + * port, a `port-mismatch` intercept is returned with the project's instances as candidates so + * callers can surface the running ports. * * If at least one record matches, dispatch based on the selected instance's `mcp.status`: * @@ -37,30 +65,37 @@ export type ResolveResult = * - `not-installed` → addon-missing intercept * - `error` → mcp-error intercept * - * Zero matches → no-instance intercept (callers may surface running cwds). 2+ matches at the same - * cwd → use the current agent to select the competing bucket, then pick the most recently started + * Zero matches → no-instance intercept (callers may surface the running instances). 2+ matches → + * use the current agent to select the competing bucket, then pick the most recently started * instance in that bucket (latest `startedAt` among `ready` records, else latest overall). Records * without a `startedAt` tie-break on lowest pid for determinism. The selected bucket is returned * (most-recent first) as `matches` so callers can warn only about instances that competed. */ export function resolveInstance( records: StorybookInstanceRecord[], - targetCwd: string, - targetPort?: number, - currentAgent?: string + target: ResolveTarget ): ResolveResult { - const normalisedTarget = resolve(targetCwd); - const cwdMatches = records.filter((r) => resolve(r.cwd) === normalisedTarget); - const matches = targetPort == null ? cwdMatches : cwdMatches.filter((r) => r.port === targetPort); + const { port: targetPort, agent: currentAgent } = target; + const normalisedCwd = resolve(target.cwd); + const normalisedConfigDir = target.configDir && resolve(target.configDir); + const matchesConfigDir = (r: StorybookInstanceRecord) => + normalisedConfigDir != null && + r.configDir != null && + resolve(r.configDir) === normalisedConfigDir; + const projectMatches = target.configDirExplicit + ? records.filter(matchesConfigDir) + : records.filter((r) => resolve(r.cwd) === normalisedCwd || matchesConfigDir(r)); + const matches = + targetPort == null ? projectMatches : projectMatches.filter((r) => r.port === targetPort); if (matches.length === 0) { - // cwd matched, but no instance there is on the requested port: a distinct, + // The project matched, but no instance there is on the requested port: a distinct, // more actionable failure than "nothing is running here". - if (targetPort != null && cwdMatches.length > 0) { + if (targetPort != null && projectMatches.length > 0) { return { kind: 'intercept', reason: 'port-mismatch', - records: cwdMatches, + records: projectMatches, matches: [], }; } diff --git a/code/core/src/cli/ai/mcp/run-tool.test.ts b/code/core/src/cli/ai/mcp/run-tool.test.ts index fae044a254ac..01c4f4dc6b77 100644 --- a/code/core/src/cli/ai/mcp/run-tool.test.ts +++ b/code/core/src/cli/ai/mcp/run-tool.test.ts @@ -232,6 +232,37 @@ describe('runAiTool', () => { }); }); + it('routes via the recorded configDir when the dev server was started from the monorepo root', async () => { + const rootInstance = { + ...record, + cwd: resolve('/repo'), + configDir: resolve('/repo/packages/ui/.storybook'), + }; + vi.mocked(readRegistry).mockResolvedValue([rootInstance]); + + const result = await runAiTool('list-all-documentation', [], { cwd: '/repo/packages/ui' }); + + expect(callMcpTool).toHaveBeenCalledWith(rootInstance, expect.anything(), undefined); + expect(result.exitCode).toBe(0); + }); + + it('routes via --config-dir from the monorepo root when the dev server was started from the leaf', async () => { + const leafInstance = { + ...record, + cwd: resolve('/repo/packages/ui'), + configDir: resolve('/repo/packages/ui/.storybook'), + }; + vi.mocked(readRegistry).mockResolvedValue([leafInstance]); + + const result = await runAiTool('list-all-documentation', [], { + cwd: '/repo', + configDir: 'packages/ui/.storybook', + }); + + expect(callMcpTool).toHaveBeenCalledWith(leafInstance, expect.anything(), undefined); + expect(result.exitCode).toBe(0); + }); + it('defaults the cwd to process.cwd()', async () => { vi.mocked(readRegistry).mockResolvedValue([{ ...record, cwd: process.cwd() }]); const result = await runAiTool('list-all-documentation', []); @@ -267,8 +298,44 @@ describe('runAiTool', () => { const result = await runAiTool('get-documentation', [], { cwd: '/projects/other' }); expect(result.exitCode).toBe(1); - expect(result.output).toContain('No Storybook is running at this cwd'); - expect(result.output).toContain('/projects/foo'); + expect(result.output).toContain('No running Storybook matches this project'); + expect(result.output).toContain('- cwd `/projects/foo` (http://localhost:6006)'); + expect(result.output).toContain('- `storybook ai --cwd /projects/foo [args...]`'); + expect(result.output).toContain('BEFORE the command name'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'no-instance' }); + }); + + it('includes a --config-dir retry example in the no-instance markdown when recorded', async () => { + vi.mocked(readRegistry).mockResolvedValue([ + { ...record, cwd: '/repo', configDir: '/repo/packages/ui/.storybook' }, + ]); + + const result = await runAiTool('get-documentation', [], { cwd: '/projects/other' }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain( + '- cwd `/repo`, config dir `/repo/packages/ui/.storybook` (http://localhost:6006)' + ); + expect(result.output).toContain( + '- `storybook ai --config-dir /repo/packages/ui/.storybook [args...]`' + ); + expect(result.output).not.toContain('storybook ai --cwd'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'no-instance' }); + }); + + it('does not route by cwd when an explicit --config-dir targets a different config', async () => { + // Only the root Storybook runs; the agent explicitly targets the (not running) ui package. + vi.mocked(readRegistry).mockResolvedValue([ + { ...record, cwd: resolve('/repo'), configDir: resolve('/repo/.storybook') }, + ]); + + const result = await runAiTool('list-all-documentation', [], { + cwd: '/repo', + configDir: 'packages/ui/.storybook', + }); + + expect(callMcpTool).not.toHaveBeenCalled(); + expect(result.exitCode).toBe(1); expect(result.outcome).toEqual({ kind: 'intercept', reason: 'no-instance' }); }); @@ -464,8 +531,8 @@ describe('runAiTool', () => { vi.mocked(readRegistry).mockResolvedValue([record, sibling]); const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); expect(result.exitCode).toBe(0); - expect(result.output).toContain('Multiple matching Storybook instances'); - expect(result.output).toContain('Matching instances at `/projects/foo`'); + expect(result.output).toContain('Multiple Storybook instances match this project'); + expect(result.output).toContain('cwd `/projects/foo`'); expect(result.output).toContain('pid `1`'); expect(result.output).toContain('pid `2`'); expect(result.output).toContain('(used)'); @@ -515,8 +582,8 @@ describe('runAiTool', () => { undefined ); expect(result.exitCode).toBe(0); - expect(result.output).toContain('Multiple matching Storybook instances'); - expect(result.output).toContain('Matching instances at `/projects/foo`'); + expect(result.output).toContain('Multiple Storybook instances match this project'); + expect(result.output).toContain('cwd `/projects/foo`'); expect(result.output).toContain('pid `2`'); expect(result.output).toContain('pid `3`'); expect(result.output).not.toContain('pid `4`'); diff --git a/code/core/src/cli/ai/mcp/run-tool.ts b/code/core/src/cli/ai/mcp/run-tool.ts index 6b3ad24770e9..7741e8955044 100644 --- a/code/core/src/cli/ai/mcp/run-tool.ts +++ b/code/core/src/cli/ai/mcp/run-tool.ts @@ -128,7 +128,10 @@ export async function runAiTool( }; } - const resolution = await resolveReadyInstance(options.cwd, parsedPort.port, deps); + const resolution = await resolveReadyInstance( + { cwd: options.cwd, configDir: options.configDir, port: parsedPort.port }, + deps + ); if (resolution.kind === 'error') { return { exitCode: 1, @@ -447,24 +450,36 @@ type InstanceResolution = }; /** - * Resolve the running Storybook instance for `cwdInput` via the registry. No version or - * installed checks: the CLI is invoked as `npx storybook`, so the fact that it is executing - * already proves the project has a compatible Storybook. + * Resolve the running Storybook instance for the targeted project via the registry. With an + * explicit `--config-dir` an instance must match that config dir; otherwise it matches on its + * recorded cwd OR its recorded config dir (defaulted to `.storybook` under the cwd), so monorepo + * instances are found from a different cwd (storybookjs/storybook#35359). No version or installed + * checks: the CLI is invoked as `npx storybook`, so the fact that it is executing already proves + * the project has a compatible Storybook. */ async function resolveReadyInstance( - cwdInput: string | undefined, - port: number | undefined, + target: { cwd?: string; configDir?: string; port?: number }, deps: AiToolRunDeps ): Promise { - const cwd = resolve(cwdInput ?? process.cwd()); + const cwd = resolve(target.cwd ?? process.cwd()); + const configDir = resolveStorybookConfigDir({ cwd, configDir: target.configDir }); const records = await readRegistry(deps.registryDir); - const resolution = resolveInstance(records, cwd, port, detectAgent()?.name); + const resolution = resolveInstance(records, { + cwd, + configDir, + configDirExplicit: target.configDir != null, + port: target.port, + agent: detectAgent()?.name, + }); if (resolution.kind === 'intercept') { return { kind: 'error', - output: getInterceptMarkdown(resolution.reason, { records: resolution.records, port }), + output: getInterceptMarkdown(resolution.reason, { + records: resolution.records, + port: target.port, + }), reason: resolution.reason, }; } @@ -562,13 +577,16 @@ function formatMultiInstanceWarning( siblings: StorybookInstanceRecord[] ): string { const all = [chosen, ...siblings]; + // Matches can span cwds (a record can match by config dir), so each line carries the + // instance's own cwd/config dir to tell the competing projects apart. const lines = all.map((r) => { const marker = r === chosen ? ' (used)' : ''; - return `> - pid \`${r.pid}\` at ${r.url} (status: \`${r.mcp.status}\`)${marker}`; + const configDir = r.configDir ? `, config dir \`${r.configDir}\`` : ''; + return `> - pid \`${r.pid}\` at ${r.url} (cwd \`${r.cwd}\`${configDir}, status: \`${r.mcp.status}\`)${marker}`; }); - return `> Warning: Multiple matching Storybook instances are running at this cwd. This call was sent to pid \`${chosen.pid}\`. + return `> Warning: Multiple Storybook instances match this project. This call was sent to pid \`${chosen.pid}\`. > -> Matching instances at \`${chosen.cwd}\`: +> Matching instances: ${lines.join('\n')} > > If results look unexpected, ask the user whether they want to stop the other instance(s).`; diff --git a/code/core/src/cli/ai/mcp/types.ts b/code/core/src/cli/ai/mcp/types.ts index 1e91bfdc08e5..4853e955ae6a 100644 --- a/code/core/src/cli/ai/mcp/types.ts +++ b/code/core/src/cli/ai/mcp/types.ts @@ -24,6 +24,12 @@ export const StorybookInstanceRecordSchema = v.object({ instanceId: v.string(), pid: v.pipe(v.number(), v.minValue(1), v.integer()), cwd: v.string(), + /** + * Resolved config directory of the running Storybook, used as a second matching key so + * monorepo instances are found from a different cwd (storybookjs/storybook#35359). Optional: + * records written by Storybooks older than 10.5 lack it. + */ + configDir: v.optional(v.string()), url: v.string(), port: v.pipe(v.number(), v.minValue(1), v.maxValue(65535), v.integer()), agent: v.optional(v.string()), diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 860f92c310df..685c214cc9a6 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -314,6 +314,7 @@ export async function buildDevStandalone( await writeStorybookRuntimeInstanceRecord({ address: localAddress, + configDir: options.configDir, mcp, port, storybookVersion, diff --git a/code/core/src/core-server/utils/runtime-instance-registry.test.ts b/code/core/src/core-server/utils/runtime-instance-registry.test.ts index 184b17b0fe14..32ee3a1bf504 100644 --- a/code/core/src/core-server/utils/runtime-instance-registry.test.ts +++ b/code/core/src/core-server/utils/runtime-instance-registry.test.ts @@ -208,6 +208,27 @@ describe('createRuntimeInstanceRecord', () => { }); }); + it('stores the configDir resolved against the cwd when provided', () => { + const record = createRuntimeInstanceRecord({ + ...baseOptions, + cwd: '/repo', + configDir: 'packages/ui/.storybook', + }); + + expect(record.configDir).toBe(resolve('/repo/packages/ui/.storybook')); + }); + + it('keeps an absolute configDir as-is', () => { + const configDir = join(tmpdir(), 'repo', 'packages', 'ui', '.storybook'); + const record = createRuntimeInstanceRecord({ ...baseOptions, cwd: '/elsewhere', configDir }); + + expect(record.configDir).toBe(resolve(configDir)); + }); + + it('omits configDir when not provided', () => { + expect(createRuntimeInstanceRecord(baseOptions)).not.toHaveProperty('configDir'); + }); + it('marks MCP as not-installed by default', () => { const record = createRuntimeInstanceRecord(baseOptions); diff --git a/code/core/src/core-server/utils/runtime-instance-registry.ts b/code/core/src/core-server/utils/runtime-instance-registry.ts index 039edbb50cc3..c7dc598025b0 100644 --- a/code/core/src/core-server/utils/runtime-instance-registry.ts +++ b/code/core/src/core-server/utils/runtime-instance-registry.ts @@ -22,6 +22,12 @@ export type RuntimeInstanceRecord = { instanceId: string; pid: number; cwd: string; + /** + * Resolved config directory of the running Storybook. Lets `storybook ai` find this instance + * from a different cwd in a monorepo (storybookjs/storybook#35359). Optional because records + * written by older Storybooks lack it. + */ + configDir?: string; url: string; port: number; agent?: string; @@ -87,6 +93,7 @@ function detectRuntimeInstanceAgent() { export function createRuntimeInstanceRecord({ address, agent, + configDir, cwd = process.cwd(), instanceId = randomUUID(), mcp = { status: 'not-installed' }, @@ -97,6 +104,7 @@ export function createRuntimeInstanceRecord({ }: { address: string; agent?: string; + configDir?: string; cwd?: string; instanceId?: string; mcp?: RuntimeInstanceRecord['mcp']; @@ -113,6 +121,7 @@ export function createRuntimeInstanceRecord({ instanceId, pid, cwd: resolve(cwd), + ...(configDir ? { configDir: resolve(cwd, configDir) } : {}), url: origin, port, ...(agent ? { agent } : {}), @@ -319,6 +328,7 @@ function registerProcessCleanup(recordPath: string) { export async function writeStorybookRuntimeInstanceRecord({ address, agent = detectRuntimeInstanceAgent(), + configDir, cwd, mcp, pid, @@ -329,6 +339,7 @@ export async function writeStorybookRuntimeInstanceRecord({ }: { address: string; agent?: string; + configDir?: string; cwd?: string; mcp?: RuntimeInstanceRecord['mcp']; pid?: number; @@ -340,6 +351,7 @@ export async function writeStorybookRuntimeInstanceRecord({ const record = createRuntimeInstanceRecord({ address, agent, + configDir, cwd, mcp, pid,