diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md new file mode 100644 index 000000000..8310d0852 --- /dev/null +++ b/.changeset/pr-1079.md @@ -0,0 +1,5 @@ +--- +'@sanity/cli': minor +--- + +Configure Sanity MCP and install the `sanity-best-practices` agent skill for detected AI editors in a single step during `sanity init`. Add `--no-skills` to opt out. diff --git a/package.json b/package.json index fcf0c3ea1..e5e9454d6 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@commitlint/types": "^20.4.0", "@eslint/compat": "catalog:", "@sanity/eslint-config-cli": "workspace:*", + "@vercel/detect-agent": "^1.2.3", "@vitest/coverage-istanbul": "catalog:", "eslint": "catalog:", "husky": "^9.1.7", diff --git a/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts b/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts index 1ce753d44..2a3f13e10 100644 --- a/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts +++ b/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts @@ -92,11 +92,11 @@ describe('#getViteConfig', () => { test('should create basic vite config with default options', async () => { const options = { cwd: mockTestCwd, - mode: 'development' as const, - reactCompiler: undefined, getEnvironmentVariables() { return {'process.env.STUDIO_VAR': '"studio-value"'} }, + mode: 'development' as const, + reactCompiler: undefined, } const config = await getViteConfig(options) @@ -142,12 +142,12 @@ describe('#getViteConfig', () => { test('should create vite config for app mode', async () => { const options = { cwd: mockTestCwd, - isApp: true, - mode: 'development' as const, - reactCompiler: undefined, getEnvironmentVariables() { return {'process.env.APP_VAR': '"app-value"'} }, + isApp: true, + mode: 'development' as const, + reactCompiler: undefined, } const config = await getViteConfig(options) @@ -168,12 +168,12 @@ describe('#getViteConfig', () => { test('should create production config with minification', async () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, minify: true, mode: 'production' as const, outputDir: mockCustomOutput, reactCompiler: undefined, sourceMap: false, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -199,10 +199,10 @@ describe('#getViteConfig', () => { test('should create production config without minification', async () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, minify: false, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -216,9 +216,9 @@ describe('#getViteConfig', () => { const options = { basePath: 'custom/path', cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, - getEnvironmentVariables, } await getViteConfig(options) @@ -229,13 +229,13 @@ describe('#getViteConfig', () => { test('should handle custom server options', async () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, server: { host: '0.0.0.0', port: 8080, }, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -257,9 +257,9 @@ describe('#getViteConfig', () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: reactCompilerConfig, - getEnvironmentVariables, } await getViteConfig(options) @@ -279,9 +279,9 @@ describe('#getViteConfig', () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -299,10 +299,10 @@ describe('#getViteConfig', () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, importMap, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, } const {createExternalFromImportMap} = await import('../createExternalFromImportMap.js') @@ -325,9 +325,9 @@ describe('#getViteConfig', () => { const options = { basePath: '/studio', cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, - getEnvironmentVariables, } await getViteConfig(options) @@ -342,6 +342,7 @@ describe('#getViteConfig', () => { test('should include schema extraction plugin when enabled', async () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, schemaExtraction: { @@ -351,7 +352,6 @@ describe('#getViteConfig', () => { watchPatterns: ['custom/**/*.ts'], workspace: 'production', }, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -375,13 +375,13 @@ describe('#getViteConfig', () => { test('should not include schema extraction plugin when disabled', async () => { const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, schemaExtraction: { enabled: false, path: 'schema.json', }, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -402,9 +402,9 @@ describe('#getViteConfig', () => { }, ], cwd: mockTestCwd, + getEnvironmentVariables, mode: 'development' as const, reactCompiler: undefined, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -585,9 +585,9 @@ describe('#onRollupWarn and #suppressUnusedImport helper functions', () => { // which includes the onwarn callback const options = { cwd: mockTestCwd, + getEnvironmentVariables, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, } const config = await getViteConfig(options) @@ -615,9 +615,9 @@ describe('#onRollupWarn and #suppressUnusedImport helper functions', () => { const config = await getViteConfig({ cwd: mockTestCwd, + getEnvironmentVariables, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, }) const onwarn = config.build?.rollupOptions?.onwarn @@ -638,9 +638,9 @@ describe('#onRollupWarn and #suppressUnusedImport helper functions', () => { const config = await getViteConfig({ cwd: mockTestCwd, + getEnvironmentVariables, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, }) const onwarn = config.build?.rollupOptions?.onwarn @@ -660,9 +660,9 @@ describe('#onRollupWarn and #suppressUnusedImport helper functions', () => { const config = await getViteConfig({ cwd: mockTestCwd, + getEnvironmentVariables, mode: 'production' as const, reactCompiler: undefined, - getEnvironmentVariables, }) const onwarn = config.build?.rollupOptions?.onwarn diff --git a/packages/@sanity/cli-build/src/actions/schema/vite/__tests__/plugin-schema-extraction.test.ts b/packages/@sanity/cli-build/src/actions/schema/vite/__tests__/plugin-schema-extraction.test.ts index ca23ed1ef..e35bb14f7 100644 --- a/packages/@sanity/cli-build/src/actions/schema/vite/__tests__/plugin-schema-extraction.test.ts +++ b/packages/@sanity/cli-build/src/actions/schema/vite/__tests__/plugin-schema-extraction.test.ts @@ -5,8 +5,8 @@ import {createMockHttpServer, createMockWatcher} from '@sanity/cli-test' import {SchemaValidationProblemGroup} from 'sanity' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {sanitySchemaExtractionPlugin} from '../plugin-schema-extraction.js' import {SchemaExtractionError} from '../../utils/SchemaExtractionError.js' +import {sanitySchemaExtractionPlugin} from '../plugin-schema-extraction.js' const mockRunSchemaExtraction = vi.hoisted(() => vi.fn()) diff --git a/packages/@sanity/cli-test/src/test/createMockWatcher.ts b/packages/@sanity/cli-test/src/test/createMockWatcher.ts index 7b1e5c51f..e995fb343 100644 --- a/packages/@sanity/cli-test/src/test/createMockWatcher.ts +++ b/packages/@sanity/cli-test/src/test/createMockWatcher.ts @@ -1,4 +1,4 @@ -import {vi, type Mock} from 'vitest' +import {type Mock, vi} from 'vitest' /** * @internal diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index c0d5b02e6..e83f547fe 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -124,6 +124,7 @@ "react-is": "^19.2.4", "rxjs": "catalog:", "semver": "^7.7.4", + "skills": "^1.5.7", "smol-toml": "^1.6.1", "tar": "^7.5.13", "tar-fs": "^3.1.2", diff --git a/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts index 4c9783bf0..1904ea72b 100644 --- a/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts +++ b/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts @@ -13,16 +13,17 @@ function defaultFlags( 'from-create': false, mcp: true, 'no-git': false, + skills: true, ...overrides, } as Parameters[0] } -/** Shorthand that fills in the trailing `args` and `mcpMode` parameters. */ +/** Shorthand that fills in the trailing `args`, `mcpMode`, and `skillsMode`. */ function toOptions( flags: Parameters[0], isUnattended: boolean, ): ReturnType { - return flagsToInitOptions(flags, isUnattended, undefined, 'prompt') + return flagsToInitOptions(flags, isUnattended, undefined, 'prompt', 'auto') } describe('flagsToInitOptions', () => { @@ -136,13 +137,24 @@ describe('flagsToInitOptions', () => { }) test('passes mcpMode through to options', () => { - const prompt = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt') + const prompt = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt', 'auto') expect(prompt.mcpMode).toBe('prompt') - const auto = flagsToInitOptions(defaultFlags(), false, undefined, 'auto') + const auto = flagsToInitOptions(defaultFlags(), false, undefined, 'auto', 'auto') expect(auto.mcpMode).toBe('auto') - const skip = flagsToInitOptions(defaultFlags(), false, undefined, 'skip') + const skip = flagsToInitOptions(defaultFlags(), false, undefined, 'skip', 'auto') expect(skip.mcpMode).toBe('skip') }) + + test('passes skillsMode through to options', () => { + const auto = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt', 'auto') + expect(auto.skillsMode).toBe('auto') + + const skip = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt', 'skip') + expect(skip.skillsMode).toBe('skip') + + const prompt = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt', 'prompt') + expect(prompt.skillsMode).toBe('prompt') + }) }) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts index c35794609..26058db25 100644 --- a/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts +++ b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts @@ -79,6 +79,7 @@ const defaultOptions: InitOptions = { datasetDefault: false, fromCreate: false, mcpMode: 'skip', + skillsMode: 'skip', unattended: false, } diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index a1fd41b74..1d798b50a 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -14,7 +14,9 @@ import {getProjectDefaults} from '../../util/getProjectDefaults.js' import {validateSession} from '../auth/ensureAuthenticated.js' import {getProviderName} from '../auth/getProviderName.js' import {login} from '../auth/login/login.js' +import {detectAvailableEditors} from '../mcp/detectAvailableEditors.js' import {setupMCP} from '../mcp/setupMCP.js' +import {setupSkills} from '../skills/setupSkills.js' import {checkNextJsReactCompatibility} from './checkNextJsReactCompatibility.js' import {determineAppTemplate} from './determineAppTemplate.js' import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' @@ -187,7 +189,18 @@ export async function initAction(options: InitOptions, context: InitContext): Pr workDir, }) - const mcpResult = await setupMCP({mode: options.mcpMode}) + // Detect editors once, then share the result with MCP and skills setup so + // we don't pay the detection cost (filesystem probes + CLI execa calls) twice. + const detectedEditors = + options.mcpMode === 'skip' && options.skillsMode === 'skip' + ? [] + : await detectAvailableEditors() + + const mcpResult = await setupMCP({ + editors: detectedEditors, + mode: options.mcpMode, + skillsMode: options.skillsMode, + }) trace.log({ configuredEditors: mcpResult.configuredEditors, @@ -200,6 +213,26 @@ export async function initAction(options: InitOptions, context: InitContext): Pr } const mcpConfigured = mcpResult.configuredEditors + async function installSkills(): Promise { + if (mcpResult.skillsToInstall.length === 0) return + try { + const skillsResult = await setupSkills({agents: mcpResult.skillsToInstall}) + trace.log({ + installedAgents: skillsResult.installedAgents, + skipped: skillsResult.skipped, + step: 'skillsSetup', + }) + if (skillsResult.error) { + trace.error(skillsResult.error) + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + debug('Unexpected error from setupSkills %O', err) + output.warn(`Could not install Sanity agent skills: ${err.message}`) + trace.error(err) + } + } + const {alreadyConfiguredEditors} = mcpResult if (alreadyConfiguredEditors.length > 0) { const label = @@ -229,6 +262,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr trace, workDir, }) + await installSkills() trace.complete() return } @@ -246,6 +280,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr outputPath, }) await writeStagingEnvIfNeeded(output, outputPath) + await installSkills() trace.complete() return } @@ -273,6 +308,8 @@ export async function initAction(options: InitOptions, context: InitContext): Pr projectId, })) + await installSkills() + trace.complete() } diff --git a/packages/@sanity/cli/src/actions/init/types.ts b/packages/@sanity/cli/src/actions/init/types.ts index 5a92e8ed9..b9189aa45 100644 --- a/packages/@sanity/cli/src/actions/init/types.ts +++ b/packages/@sanity/cli/src/actions/init/types.ts @@ -25,6 +25,7 @@ export interface InitOptions { datasetDefault: boolean fromCreate: boolean mcpMode: 'auto' | 'prompt' | 'skip' + skillsMode: 'auto' | 'prompt' | 'skip' unattended: boolean argType?: string @@ -64,6 +65,7 @@ interface InitCommandFlags { 'from-create': boolean mcp: boolean 'no-git': boolean + skills: boolean coupon?: string 'create-project'?: string @@ -112,6 +114,7 @@ export function flagsToInitOptions( isUnattended: boolean, args: InitCommandArgs | undefined, mcpMode: InitOptions['mcpMode'], + skillsMode: InitOptions['skillsMode'], ): InitOptions { return { argType: args?.type, @@ -137,6 +140,7 @@ export function flagsToInitOptions( projectPlan: flags['project-plan'], provider: flags.provider, reconfigure: flags.reconfigure, + skillsMode, template: flags.template, templateToken: flags['template-token'], typescript: flags.typescript, diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts index 5df006769..fdad7a971 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts @@ -1,5 +1,6 @@ import {afterEach, describe, expect, test, vi} from 'vitest' +import {type EditorChoice} from '../promptForMCPSetup.js' import {type Editor} from '../types.js' const mockCheckbox = vi.hoisted(() => vi.fn()) @@ -18,6 +19,10 @@ function makeEditor(overrides: Partial & Pick): Editor { } } +function choice(editor: Editor, action: EditorChoice['action'] = 'mcp-and-skill'): EditorChoice { + return {action, editor} +} + describe('promptForMCPSetup', () => { afterEach(() => { vi.clearAllMocks() @@ -26,12 +31,15 @@ describe('promptForMCPSetup', () => { test('labels unconfigured editors with plain name', async () => { mockCheckbox.mockResolvedValue(['Cursor']) - const editors = [makeEditor({name: 'Cursor'})] - await promptForMCPSetup(editors) + await promptForMCPSetup({ + choices: [choice(makeEditor({name: 'Cursor'}), 'mcp-and-skill')], + message: 'Configure Sanity MCP and install agent skills for these editors?', + }) expect(mockCheckbox).toHaveBeenCalledWith( expect.objectContaining({ choices: [{checked: true, name: 'Cursor', value: 'Cursor'}], + message: 'Configure Sanity MCP and install agent skills for these editors?', }), ) }) @@ -39,15 +47,13 @@ describe('promptForMCPSetup', () => { test('labels editors with expired auth as "(auth expired)"', async () => { mockCheckbox.mockResolvedValue(['Cursor']) - const editors = [ - makeEditor({ - authStatus: 'unauthorized', - configured: true, - existingToken: 'old', - name: 'Cursor', - }), - ] - await promptForMCPSetup(editors) + const editor = makeEditor({ + authStatus: 'unauthorized', + configured: true, + existingToken: 'old', + name: 'Cursor', + }) + await promptForMCPSetup({choices: [choice(editor, 'mcp-and-skill')], message: 'q?'}) expect(mockCheckbox).toHaveBeenCalledWith( expect.objectContaining({ @@ -59,8 +65,8 @@ describe('promptForMCPSetup', () => { test('labels configured editors without token as "(missing credentials)"', async () => { mockCheckbox.mockResolvedValue(['Cursor']) - const editors = [makeEditor({configured: true, name: 'Cursor'})] - await promptForMCPSetup(editors) + const editor = makeEditor({configured: true, name: 'Cursor'}) + await promptForMCPSetup({choices: [choice(editor, 'mcp-and-skill')], message: 'q?'}) expect(mockCheckbox).toHaveBeenCalledWith( expect.objectContaining({ @@ -69,22 +75,50 @@ describe('promptForMCPSetup', () => { ) }) + test('labels skill-only action distinctly', async () => { + mockCheckbox.mockResolvedValue(['Cursor']) + + const editor = makeEditor({ + authStatus: 'valid', + configured: true, + existingToken: 'tok', + name: 'Cursor', + }) + await promptForMCPSetup({choices: [choice(editor, 'skill-only')], message: 'q?'}) + + expect(mockCheckbox).toHaveBeenCalledWith( + expect.objectContaining({ + choices: [ + { + checked: true, + name: 'Cursor (skill only — MCP already configured)', + value: 'Cursor', + }, + ], + }), + ) + }) + test('returns null when user deselects all editors', async () => { mockCheckbox.mockResolvedValue([]) - const editors = [makeEditor({name: 'Cursor'})] - const result = await promptForMCPSetup(editors) + const result = await promptForMCPSetup({ + choices: [choice(makeEditor({name: 'Cursor'}))], + message: 'q?', + }) expect(result).toBeNull() }) - test('returns only selected editors', async () => { + test('returns only selected choices', async () => { mockCheckbox.mockResolvedValue(['VS Code']) - const editors = [makeEditor({name: 'Cursor'}), makeEditor({name: 'VS Code'})] - const result = await promptForMCPSetup(editors) + const result = await promptForMCPSetup({ + choices: [choice(makeEditor({name: 'Cursor'})), choice(makeEditor({name: 'VS Code'}))], + message: 'q?', + }) expect(result).toHaveLength(1) - expect(result![0].name).toBe('VS Code') + expect(result![0].editor.name).toBe('VS Code') }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts index 9ce131371..78c342886 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -1,12 +1,14 @@ import {afterEach, describe, expect, test, vi} from 'vitest' import {setupMCP} from '../setupMCP.js' +import {type Editor} from '../types.js' const mockDetectAvailableEditors = vi.hoisted(() => vi.fn()) const mockPromptForMCPSetup = vi.hoisted(() => vi.fn()) const mockValidateEditorTokens = vi.hoisted(() => vi.fn()) const mockCreateMCPToken = vi.hoisted(() => vi.fn()) const mockWriteMCPConfig = vi.hoisted(() => vi.fn()) +const mockReadSkillState = vi.hoisted(() => vi.fn()) vi.mock('../detectAvailableEditors.js', () => ({ detectAvailableEditors: mockDetectAvailableEditors, @@ -29,75 +31,333 @@ vi.mock('../writeMCPConfig.js', () => ({ writeMCPConfig: mockWriteMCPConfig, })) +vi.mock('../../skills/readSkillState.js', () => ({ + readSkillState: mockReadSkillState, +})) + +function editor(overrides: Partial & Pick): Editor { + return { + configPath: `/fake/${overrides.name}/config.json`, + configured: false, + ...overrides, + } +} + +function defaultMocks() { + mockValidateEditorTokens.mockResolvedValue(undefined) + mockCreateMCPToken.mockResolvedValue('test-token') + mockWriteMCPConfig.mockResolvedValue(undefined) + mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set()}) +} + describe('setupMCP', () => { afterEach(() => { vi.clearAllMocks() }) - test('mode: skip returns early without detecting editors', async () => { + // ------------------------------------------------------------------------- + // legacy MCP-only behavior (skillsMode defaulting to 'skip') + // ------------------------------------------------------------------------- + + test('mcpMode: skip with default skillsMode skip short-circuits', async () => { const result = await setupMCP({mode: 'skip'}) expect(result.skipped).toBe(true) - expect(result.configuredEditors).toEqual([]) - expect(result.detectedEditors).toEqual([]) + expect(result.skillsToInstall).toEqual([]) expect(mockDetectAvailableEditors).not.toHaveBeenCalled() + expect(mockReadSkillState).not.toHaveBeenCalled() }) - test('mode: auto auto-selects all actionable editors without prompting', async () => { + test('mcpMode: auto auto-selects actionable editors and writes configs', async () => { + defaultMocks() mockDetectAvailableEditors.mockResolvedValue([ - {authStatus: 'unknown', configured: false, name: 'Cursor'}, - {authStatus: 'unknown', configured: false, name: 'VS Code'}, + editor({name: 'Cursor'}), + editor({name: 'VS Code'}), ]) - mockValidateEditorTokens.mockResolvedValue(undefined) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) const result = await setupMCP({mode: 'auto'}) expect(mockPromptForMCPSetup).not.toHaveBeenCalled() expect(mockWriteMCPConfig).toHaveBeenCalledTimes(2) expect(result.configuredEditors).toEqual(['Cursor', 'VS Code']) + expect(result.skillsToInstall).toEqual([]) expect(result.skipped).toBe(false) }) - test('mode: prompt calls promptForMCPSetup', async () => { - const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + test('mcpMode: prompt with skillsMode skip uses today’s message', async () => { + defaultMocks() + const editors = [editor({name: 'Cursor'})] mockDetectAvailableEditors.mockResolvedValue(editors) - mockValidateEditorTokens.mockResolvedValue(undefined) - mockPromptForMCPSetup.mockResolvedValue(editors) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([{action: 'mcp-only', editor: editors[0]}]) const result = await setupMCP({mode: 'prompt'}) - expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) + expect(mockPromptForMCPSetup).toHaveBeenCalledWith( + expect.objectContaining({message: 'Configure Sanity MCP server?'}), + ) expect(result.configuredEditors).toEqual(['Cursor']) - expect(result.skipped).toBe(false) }) - test('defaults to prompt mode when no mode specified', async () => { - const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + test('uses caller-provided editors without re-detecting', async () => { + defaultMocks() + const editors: Editor[] = [{configPath: '/tmp/cursor', configured: false, name: 'Cursor'}] + + const result = await setupMCP({editors, mode: 'auto'}) + + expect(mockDetectAvailableEditors).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual(['Cursor']) + }) + + test('returns skipped when all detected editors are already configured', async () => { + defaultMocks() + const editors = [ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ] mockDetectAvailableEditors.mockResolvedValue(editors) - mockValidateEditorTokens.mockResolvedValue(undefined) - mockPromptForMCPSetup.mockResolvedValue(editors) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) - await setupMCP() + const result = await setupMCP({mode: 'auto'}) + + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + }) + + // ------------------------------------------------------------------------- + // combined flow — classification matrix + // ------------------------------------------------------------------------- + + test('mcp-and-skill: actionable editor with skill mapping, skill not installed', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Cursor'})]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(mockReadSkillState).toHaveBeenCalledWith({skillName: 'sanity-best-practices'}) + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('mcp-and-skill: skill installed already still installs after MCP write (no-op upstream)', async () => { + defaultMocks() + mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set(['Cursor'])}) + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Cursor'})]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) - expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual(['cursor']) }) - test('defaults to prompt mode when options provided without mode', async () => { - const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + test('skill-only: MCP already valid, skill missing', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual([]) + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('mcp-only: editor without a skills-CLI mapping (Zed)', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Zed'})]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(result.configuredEditors).toEqual(['Zed']) + expect(result.skillsToInstall).toEqual([]) + }) + + test('none: MCP valid + skill installed → already configured', async () => { + defaultMocks() + mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set(['Cursor'])}) + mockDetectAvailableEditors.mockResolvedValue([ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual([]) + }) + + test('none: MCP valid for editor without skill mapping (Zed)', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Zed'}), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Zed']) + expect(result.skillsToInstall).toEqual([]) + }) + + // ------------------------------------------------------------------------- + // masking + // ------------------------------------------------------------------------- + + test('mcpMode skip downgrades mcp-and-skill to skill-only', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Cursor'}), editor({name: 'Zed'})]) + + const result = await setupMCP({mode: 'skip', skillsMode: 'auto'}) + + // Zed (mcp-only) gets dropped; Cursor downgrades to skill-only — no MCP write + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual([]) + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('skillsMode skip downgrades mcp-and-skill to mcp-only', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([ + editor({name: 'Cursor'}), + editor({ + authStatus: 'valid', + configured: true, + existingToken: 'tok', + name: 'Claude Code', + }), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'skip'}) + + expect(mockReadSkillState).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual([]) + }) + + test('both skip short-circuits even with editors provided', async () => { + const result = await setupMCP({ + editors: [editor({name: 'Cursor'})], + mode: 'skip', + skillsMode: 'skip', + }) + + expect(result.skipped).toBe(true) + expect(result.skillsToInstall).toEqual([]) + expect(mockValidateEditorTokens).not.toHaveBeenCalled() + }) + + // ------------------------------------------------------------------------- + // prompt + selection behavior + // ------------------------------------------------------------------------- + + test('combined prompt message used when both modes are prompt', async () => { + defaultMocks() + const editors = [editor({name: 'Cursor'})] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockPromptForMCPSetup.mockResolvedValue([{action: 'mcp-and-skill', editor: editors[0]}]) + + await setupMCP({mode: 'prompt', skillsMode: 'prompt'}) + + expect(mockPromptForMCPSetup).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Configure Sanity MCP and install agent skills for these editors?', + }), + ) + }) + + test('mcpMode prompt + skillsMode auto still prompts the user', async () => { + defaultMocks() + const editors = [editor({name: 'Cursor'})] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockPromptForMCPSetup.mockResolvedValue([{action: 'mcp-and-skill', editor: editors[0]}]) + + const result = await setupMCP({mode: 'prompt', skillsMode: 'auto'}) + + expect(mockPromptForMCPSetup).toHaveBeenCalled() + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('mcpMode skip + skillsMode auto auto-selects skill-only without prompting', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ]) + + const result = await setupMCP({mode: 'skip', skillsMode: 'auto'}) + + expect(mockPromptForMCPSetup).not.toHaveBeenCalled() + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('skill-only prompt message used when mcpMode is skip + skillsMode is prompt', async () => { + defaultMocks() + const editors = [ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ] mockDetectAvailableEditors.mockResolvedValue(editors) - mockValidateEditorTokens.mockResolvedValue(undefined) - mockPromptForMCPSetup.mockResolvedValue(editors) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([{action: 'skill-only', editor: editors[0]}]) + + const result = await setupMCP({mode: 'skip', skillsMode: 'prompt'}) + + expect(mockPromptForMCPSetup).toHaveBeenCalledWith( + expect.objectContaining({message: 'Install Sanity agent skills for these editors?'}), + ) + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('user deselects all → no MCP writes, no skillsToInstall', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Cursor'})]) + mockPromptForMCPSetup.mockResolvedValue(null) + + const result = await setupMCP({mode: 'prompt', skillsMode: 'prompt'}) + + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.skillsToInstall).toEqual([]) + expect(result.skipped).toBe(true) + }) + + // ------------------------------------------------------------------------- + // failure handling + // ------------------------------------------------------------------------- + + test('MCP write failure excludes the failing editor from skillsToInstall', async () => { + defaultMocks() + const cursor = editor({name: 'Cursor'}) + const claudeCode = editor({name: 'Claude Code'}) + mockDetectAvailableEditors.mockResolvedValue([cursor, claudeCode]) + mockWriteMCPConfig.mockImplementation(async (e: Editor) => { + if (e.name === 'Cursor') throw new Error('disk full') + }) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(result.configuredEditors).toEqual(['Claude Code']) + expect(result.skillsToInstall).toEqual(['claude-code']) + expect(result.error).toBeInstanceOf(Error) + }) + + test('skill state probe failure → over-install (treat all as not installed)', async () => { + defaultMocks() + mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set()}) + mockDetectAvailableEditors.mockResolvedValue([ + editor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + expect(result.skillsToInstall).toEqual(['cursor']) + }) + + test('dedups multiple editors mapping to the same skills-CLI agent', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([ + editor({name: 'VS Code'}), + editor({name: 'VS Code Insiders'}), + ]) - await setupMCP({explicit: true}) + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) - expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) + expect(result.skillsToInstall).toEqual(['github-copilot']) }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts index baed142fa..5e1530ac3 100644 --- a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts +++ b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts @@ -44,6 +44,11 @@ interface EditorConfig { /** If true, this editor uses OAuth natively and does not need an embedded API token */ oauthOnly?: boolean + /** + * Corresponding `--agent` value for the `skills` CLI (https://github.com/vercel-labs/skills). + * Omit when the editor has no skills CLI equivalent. + */ + skillsCliAgent?: string } /** @@ -295,19 +300,31 @@ export const EDITOR_CONFIGS = { ...EDITOR_DEFAULTS, buildServerConfig: buildAntigravityServerConfig, detect: detectAntigravity, + skillsCliAgent: 'antigravity', }, // Doc: https://docs.anthropic.com/en/docs/claude-code/mcp // Path: ~/.claude.json Key: mcpServers - 'Claude Code': {...EDITOR_DEFAULTS, detect: detectClaudeCode, oauthOnly: true}, + 'Claude Code': { + ...EDITOR_DEFAULTS, + detect: detectClaudeCode, + oauthOnly: true, + skillsCliAgent: 'claude-code', + }, // Doc: https://github.com/cline/cline — VS Code extension (saoudrizwan.claude-dev) // Path: /globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json - Cline: {...EDITOR_DEFAULTS, buildServerConfig: buildClineServerConfig, detect: detectCline}, + Cline: { + ...EDITOR_DEFAULTS, + buildServerConfig: buildClineServerConfig, + detect: detectCline, + skillsCliAgent: 'cline', + }, // Doc: https://github.com/cline/cline — standalone CLI mode // Path: $CLINE_DIR || ~/.cline/data/settings/cline_mcp_settings.json 'Cline CLI': { ...EDITOR_DEFAULTS, buildServerConfig: buildClineServerConfig, detect: detectClineCli, + skillsCliAgent: 'cline', }, // Doc: https://platform.openai.com/docs/guides/tools-remote-mcp#codex-cli // Path: $CODEX_HOME || ~/.codex/config.toml Key: mcp_servers Format: TOML @@ -318,6 +335,7 @@ export const EDITOR_CONFIGS = { detect: detectCodexCli, format: 'toml' as const, readToken: readTokenFromHttpHeaders, + skillsCliAgent: 'codex', }, // Doc: https://docs.cursor.com/context/model-context-protocol // Path: ~/.cursor/mcp.json Key: mcpServers @@ -325,16 +343,18 @@ export const EDITOR_CONFIGS = { ...EDITOR_DEFAULTS, detect: detectCursor, oauthOnly: true, + skillsCliAgent: 'cursor', }, // Doc: https://googlegemini.wiki/gemini-cli/mcp-servers // Path: ~/.gemini/settings.json Key: mcpServers - 'Gemini CLI': {...EDITOR_DEFAULTS, detect: detectGeminiCli}, + 'Gemini CLI': {...EDITOR_DEFAULTS, detect: detectGeminiCli, skillsCliAgent: 'gemini-cli'}, // Doc: https://docs.github.com/en/copilot/customizing-copilot/extending-copilot-coding-agent-with-mcp // Path: ~/.copilot/mcp-config.json (or $XDG_CONFIG_HOME/copilot on Linux) Key: mcpServers 'GitHub Copilot CLI': { ...EDITOR_DEFAULTS, buildServerConfig: buildGitHubCopilotCliServerConfig, detect: detectGitHubCopilotCli, + skillsCliAgent: 'github-copilot', }, // Doc: https://github.com/nicobailon/mcporter // Path: ~/.mcporter/mcporter.{json,jsonc} Key: mcpServers @@ -346,15 +366,29 @@ export const EDITOR_CONFIGS = { buildServerConfig: buildOpenCodeServerConfig, configKey: 'mcp', detect: detectOpenCode, + skillsCliAgent: 'opencode', }, // Doc: https://code.visualstudio.com/docs/copilot/chat/mcp-servers // Path: /mcp.json Key: servers - 'VS Code': {...EDITOR_DEFAULTS, configKey: 'servers', detect: detectVSCode}, + // VS Code uses GitHub Copilot for AI features; skills are installed via the + // `github-copilot` agent (see https://code.visualstudio.com/docs/copilot/customization/agent-skills). + 'VS Code': { + ...EDITOR_DEFAULTS, + configKey: 'servers', + detect: detectVSCode, + skillsCliAgent: 'github-copilot', + }, // Doc: https://code.visualstudio.com/docs/copilot/chat/mcp-servers // Path: /mcp.json Key: servers - 'VS Code Insiders': {...EDITOR_DEFAULTS, configKey: 'servers', detect: detectVSCodeInsiders}, + 'VS Code Insiders': { + ...EDITOR_DEFAULTS, + configKey: 'servers', + detect: detectVSCodeInsiders, + skillsCliAgent: 'github-copilot', + }, // Doc: https://zed.dev/docs/assistant/model-context-protocol // Path: ~/.config/zed/settings.json (or $APPDATA/Zed on Windows) Key: context_servers + // Zed doesn't support agent skills - https://github.com/zed-industries/zed/issues/49057 Zed: { ...EDITOR_DEFAULTS, buildServerConfig: buildZedServerConfig, @@ -365,3 +399,33 @@ export const EDITOR_CONFIGS = { /** Derived from EDITOR_CONFIGS keys - add a new editor there and this updates automatically */ export type EditorName = keyof typeof EDITOR_CONFIGS + +export function getSkillsCliAgent(editorName: EditorName): string | undefined { + if (editorName in EDITOR_CONFIGS) { + const config = EDITOR_CONFIGS[editorName] + return 'skillsCliAgent' in config ? config.skillsCliAgent : undefined + } +} + +/** + * Skills-CLI agent ID → display name. Mirrors `displayName` from + * `~/git/skills/src/agents.ts` for the subset of agents we install for. Used + * to match `skills list --json` output (which keys by display name) against + * our editors. + */ +const SKILLS_CLI_AGENT_DISPLAY_NAMES: Record = { + antigravity: 'Antigravity', + 'claude-code': 'Claude Code', + cline: 'Cline', + codex: 'Codex', + cursor: 'Cursor', + 'gemini-cli': 'Gemini CLI', + 'github-copilot': 'GitHub Copilot', + opencode: 'OpenCode', +} + +/** Display name used by the skills CLI for the given editor, if it has a mapping. */ +export function getSkillsCliAgentDisplayName(editorName: EditorName): string | undefined { + const agent = getSkillsCliAgent(editorName) + return agent ? SKILLS_CLI_AGENT_DISPLAY_NAMES[agent] : undefined +} diff --git a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts index f01aae84a..27700cc64 100644 --- a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts +++ b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts @@ -2,7 +2,19 @@ import {checkbox} from '@sanity/cli-core/ux' import {type Editor} from './types.js' -function getEditorLabel(editor: Editor): string { +/** Action to take for an editor in the combined MCP + skills setup prompt. */ +export type EditorAction = 'mcp-and-skill' | 'mcp-only' | 'skill-only' + +export interface EditorChoice { + action: EditorAction + editor: Editor +} + +function getEditorLabel(choice: EditorChoice): string { + const {action, editor} = choice + if (action === 'skill-only') { + return `${editor.name} (skill only — MCP already configured)` + } if (editor.configured && editor.authStatus === 'unauthorized') { return `${editor.name} (auth expired)` } @@ -12,28 +24,37 @@ function getEditorLabel(editor: Editor): string { return editor.name } +interface PromptOptions { + choices: EditorChoice[] + message: string +} + /** - * Prompt user to select which editors to configure. + * Prompt the user to select editors for MCP / skills setup. The caller is + * responsible for classifying editors into actions (see `EditorAction`) and + * for choosing an appropriate prompt message. * - * Expects only actionable editors (unconfigured, or configured with - * invalid/missing credentials). Annotates entries with auth status. + * Returns the subset of `choices` the user kept, or `null` when the user + * deselected everything. */ -export async function promptForMCPSetup(editors: Editor[]): Promise { - const editorChoices = editors.map((e) => ({ - checked: true, // Pre-select all actionable editors - name: getEditorLabel(e), - value: e.name, +export async function promptForMCPSetup({ + choices, + message, +}: PromptOptions): Promise { + const editorChoices = choices.map((choice) => ({ + checked: true, + name: getEditorLabel(choice), + value: choice.editor.name, })) const selectedNames = await checkbox({ choices: editorChoices, - message: 'Configure Sanity MCP server?', + message, }) - // User can deselect all to skip if (!selectedNames || selectedNames.length === 0) { return null } - return editors.filter((e) => selectedNames.includes(e.name)) + return choices.filter((c) => selectedNames.includes(c.editor.name)) } diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 28bf6372a..291acdca0 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -3,9 +3,17 @@ import {subdebug} from '@sanity/cli-core' import {logSymbols} from '@sanity/cli-core/ux' import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js' +import {readSkillState} from '../skills/readSkillState.js' +import {SANITY_SKILL_NAME} from '../skills/setupSkills.js' import {detectAvailableEditors} from './detectAvailableEditors.js' -import {EDITOR_CONFIGS, type EditorName} from './editorConfigs.js' -import {promptForMCPSetup} from './promptForMCPSetup.js' +import { + EDITOR_CONFIGS, + type EditorName, + getSkillsCliAgent, + getSkillsCliAgentDisplayName, +} from './editorConfigs.js' +import {type EditorAction, type EditorChoice, promptForMCPSetup} from './promptForMCPSetup.js' +import {type Editor} from './types.js' import {validateEditorTokens} from './validateEditorTokens.js' import {writeMCPConfig} from './writeMCPConfig.js' @@ -13,7 +21,17 @@ const mcpDebug = subdebug('mcp:setup') const NO_EDITORS_DETECTED_MESSAGE = `Couldn't auto-configure Sanity MCP server for your editor. Visit ${MCP_SERVER_URL} for setup instructions.` +type Mode = 'auto' | 'prompt' | 'skip' + interface MCPSetupOptions { + /** + * Pre-detected editors. When omitted, `detectAvailableEditors()` is called. + * Accepting this from the caller avoids re-running detection (which probes + * the filesystem and shells out to CLI binaries) when the result is already + * available — e.g. when `sanity init` runs both MCP and skills setup. + */ + editors?: Editor[] + /** * Whether the user explicitly requested MCP configuration (e.g. `sanity mcp configure`). * When true, shows status messages even when there's nothing to do. @@ -27,7 +45,15 @@ interface MCPSetupOptions { * - 'auto': Auto-configure all detected editors without prompting * - 'skip': Skip MCP configuration entirely */ - mode?: 'auto' | 'prompt' | 'skip' + mode?: Mode + + /** + * Controls whether skills install is also offered in the same prompt: + * - 'prompt': Combine MCP + skills offers in one checkbox + * - 'auto': Combine, but skip the prompt and select everything + * - 'skip' (default): Skip skills entirely — today's MCP-only behavior + */ + skillsMode?: Mode } interface MCPSetupResult { @@ -35,31 +61,114 @@ interface MCPSetupResult { alreadyConfiguredEditors: EditorName[] configuredEditors: EditorName[] detectedEditors: EditorName[] + /** Skills-CLI agent IDs that the caller should install. Deduplicated. */ + skillsToInstall: string[] skipped: boolean error?: Error } +interface ClassifiedEditor { + action: 'none' | EditorAction + editor: Editor +} + +/** + * Classify each editor into one of four actions based on MCP status and + * whether a Sanity skill is already installed for its skills-CLI agent. + */ +function classifyEditors(editors: Editor[]): ClassifiedEditor[] { + return editors.map((editor) => { + const needsMCP = !editor.configured || editor.authStatus !== 'valid' + const skillsCliAgent = getSkillsCliAgent(editor.name) + const hasSkillMapping = Boolean(skillsCliAgent) + const skillInstalled = editor.skillInstalled === true + + if (needsMCP) { + return {action: hasSkillMapping ? 'mcp-and-skill' : 'mcp-only', editor} + } + if (hasSkillMapping && !skillInstalled) { + return {action: 'skill-only', editor} + } + return {action: 'none', editor} + }) +} + /** - * Main MCP setup orchestration - * Opt-out by default: runs automatically unless skip option is set + * Apply masking based on the configured modes. `skip` modes mute the + * corresponding action so we never prompt for or run work the user opted out + * of via `--no-mcp` / `--no-skills`. + */ +function applyMasking( + classified: ClassifiedEditor[], + mcpMode: Mode, + skillsMode: Mode, +): EditorChoice[] { + const actionable: EditorChoice[] = [] + + for (const {action, editor} of classified) { + if (action === 'none') continue + + if (mcpMode === 'skip' && skillsMode === 'skip') continue + + if (mcpMode === 'skip') { + // No MCP writes — keep only skill-only / mcp-and-skill (downgraded to skill-only) + if (action === 'mcp-only') continue + if (action === 'mcp-and-skill') { + actionable.push({action: 'skill-only', editor}) + continue + } + actionable.push({action, editor}) + continue + } + + if (skillsMode === 'skip') { + // No skill install — drop skill-only, downgrade mcp-and-skill → mcp-only + if (action === 'skill-only') continue + if (action === 'mcp-and-skill') { + actionable.push({action: 'mcp-only', editor}) + continue + } + actionable.push({action, editor}) + continue + } + + actionable.push({action, editor}) + } + + return actionable +} + +function getPromptMessage(mcpMode: Mode, skillsMode: Mode): string { + if (mcpMode === 'skip') return 'Install Sanity agent skills for these editors?' + if (skillsMode === 'skip') return 'Configure Sanity MCP server?' + return 'Configure Sanity MCP and install agent skills for these editors?' +} + +/** + * Main MCP setup orchestration. + * + * When `skillsMode !== 'skip'`, the prompt combines MCP and skill offers, + * and the result includes `skillsToInstall` — agent IDs the caller should + * install via `setupSkills`. `setupMCP` itself never installs skills. */ export async function setupMCP(options?: MCPSetupOptions): Promise { - const {explicit = false, mode = 'prompt'} = options ?? {} + const {explicit = false, mode: mcpMode = 'prompt', skillsMode = 'skip'} = options ?? {} - // 1. Check for explicit opt-out - if (mode === 'skip') { - mcpDebug('Skipping MCP configuration (mode: skip)') + // 1. Both opted out → nothing to do. + if (mcpMode === 'skip' && skillsMode === 'skip') { + mcpDebug('Skipping setup (mcpMode: skip, skillsMode: skip)') return { alreadyConfiguredEditors: [], configuredEditors: [], detectedEditors: [], + skillsToInstall: [], skipped: true, } } // 2. Detect available editors (filters out unparseable configs) - const editors = await detectAvailableEditors() + const editors = options?.editors ?? (await detectAvailableEditors()) const detectedEditors = editors.map((e) => e.name) mcpDebug('Detected %d editors: %s', detectedEditors.length, detectedEditors) @@ -72,6 +181,7 @@ export async function setupMCP(options?: MCPSetupOptions): Promise !e.configured || e.authStatus !== 'valid') + // 4. Read skill state when skills are in scope so classification can dedup + if (skillsMode !== 'skip') { + const {installedAgentDisplayNames} = await readSkillState({skillName: SANITY_SKILL_NAME}) + for (const editor of editors) { + const displayName = getSkillsCliAgentDisplayName(editor.name) + editor.skillInstalled = displayName ? installedAgentDisplayNames.has(displayName) : false + } + } + + // 5. Classify + mask + const classified = classifyEditors(editors) + const actionable = applyMasking(classified, mcpMode, skillsMode) + + // "Already configured" surfaces editors whose MCP setup is valid (skill + // state doesn't matter for this signal — that's what skillsToInstall is for). + const actionableNames = new Set(actionable.map((c) => c.editor.name)) + const alreadyConfiguredEditors = editors + .filter((e) => e.configured && e.authStatus === 'valid' && !actionableNames.has(e.name)) + .map((e) => e.name) if (actionable.length === 0) { - mcpDebug('All editors configured with valid credentials') - const alreadyConfiguredEditors = editors - .filter((e) => e.configured && e.authStatus === 'valid') - .map((e) => e.name) + mcpDebug('Nothing actionable after classification + masking') if (explicit) { ux.stdout(`${logSymbols.success} All detected editors are already configured`) } @@ -94,86 +218,107 @@ export async function setupMCP(options?: MCPSetupOptions): Promise !actionable.includes(e)).map((e) => e.name) - - // 5. Select editors to configure — prompt interactively or auto-select all - const selected = mode === 'auto' ? actionable : await promptForMCPSetup(actionable) + // 6. Select editors to configure — prompt interactively or auto-select all. + // We only auto when neither side wants a prompt: MCP auto, or (MCP skip + // + skills auto). Anything that asks `mode: 'prompt'` for MCP wins the + // prompt even when skills would have auto-installed. + const shouldAuto = mcpMode === 'auto' || (mcpMode === 'skip' && skillsMode === 'auto') + const selected = shouldAuto + ? actionable + : await promptForMCPSetup({ + choices: actionable, + message: getPromptMessage(mcpMode, skillsMode), + }) if (!selected || selected.length === 0) { - // User deselected all editors ux.stdout('MCP configuration skipped') return { alreadyConfiguredEditors, configuredEditors: [], detectedEditors, + skillsToInstall: [], skipped: true, } } - // 6. Get a token — reuse a valid existing one or create a new one + // 7. MCP write phase — only for choices that need MCP + const mcpSelected = selected.filter( + (c) => c.action === 'mcp-only' || c.action === 'mcp-and-skill', + ) + let token: string | undefined + const configuredEditors: EditorName[] = [] + let mcpError: Error | undefined - // Look for an existing valid token we can reuse - const validEditor = editors.find((e) => e.authStatus === 'valid' && e.existingToken) - if (validEditor?.existingToken) { - mcpDebug('Reusing valid token from %s', validEditor.name) - token = validEditor.existingToken - } + if (mcpSelected.length > 0) { + const validEditor = editors.find((e) => e.authStatus === 'valid' && e.existingToken) + if (validEditor?.existingToken) { + mcpDebug('Reusing valid token from %s', validEditor.name) + token = validEditor.existingToken + } - const allOAuth = selected.every((e) => EDITOR_CONFIGS[e.name].oauthOnly) - - // Fall back to creating a new token - // If all editors use OAuth, we don't need to create a token - if (!token && !allOAuth) { - try { - token = await createMCPToken() - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)) - mcpDebug('Error creating MCP token', error) - ux.warn(`Could not configure MCP: ${err.message}`) - ux.warn('You can set up MCP manually later using https://mcp.sanity.io') - return { - alreadyConfiguredEditors, - configuredEditors: [], - detectedEditors, - error: err, - skipped: false, + const allOAuth = mcpSelected.every((c) => EDITOR_CONFIGS[c.editor.name].oauthOnly) + + if (!token && !allOAuth) { + try { + token = await createMCPToken() + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + mcpDebug('Error creating MCP token', error) + ux.warn(`Could not configure MCP: ${err.message}`) + ux.warn('You can set up MCP manually later using https://mcp.sanity.io') + mcpError = err } } - } - // 7. Write configs for each selected editor - const configuredEditors: EditorName[] = [] - try { - for (const editor of selected) { - await writeMCPConfig(editor, token) - configuredEditors.push(editor.name) - } - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)) - mcpDebug('Error writing MCP config', error) - ux.warn(`Could not configure MCP: ${err.message}`) - ux.warn('You can set up MCP manually later using https://mcp.sanity.io') - return { - alreadyConfiguredEditors, - configuredEditors, - detectedEditors, - error: err, - skipped: false, + if (!mcpError) { + for (const choice of mcpSelected) { + try { + await writeMCPConfig(choice.editor, token) + configuredEditors.push(choice.editor.name) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + mcpDebug('Error writing MCP config for %s: %O', choice.editor.name, error) + ux.warn(`Could not configure MCP for ${choice.editor.name}: ${err.message}`) + ux.warn('You can set up MCP manually later using https://mcp.sanity.io') + mcpError = err + } + } + } + + if (configuredEditors.length > 0) { + ux.stdout(`${logSymbols.success} MCP configured for ${configuredEditors.join(', ')}`) } } - ux.stdout(`${logSymbols.success} MCP configured for ${configuredEditors.join(', ')}`) + // 8. Build skillsToInstall — only for choices the user kept, only when the + // associated MCP write succeeded (or wasn't needed). + const skillsToInstall: string[] = [] + if (skillsMode !== 'skip') { + for (const choice of selected) { + if (choice.action === 'skill-only') { + const agent = getSkillsCliAgent(choice.editor.name) + if (agent) skillsToInstall.push(agent) + continue + } + if (choice.action === 'mcp-and-skill' && configuredEditors.includes(choice.editor.name)) { + const agent = getSkillsCliAgent(choice.editor.name) + if (agent) skillsToInstall.push(agent) + } + } + } return { alreadyConfiguredEditors, configuredEditors, detectedEditors, + error: mcpError, + skillsToInstall: [...new Set(skillsToInstall)], skipped: false, } } diff --git a/packages/@sanity/cli/src/actions/mcp/types.ts b/packages/@sanity/cli/src/actions/mcp/types.ts index 78c236775..b080806e8 100644 --- a/packages/@sanity/cli/src/actions/mcp/types.ts +++ b/packages/@sanity/cli/src/actions/mcp/types.ts @@ -16,4 +16,11 @@ export interface Editor { authStatus?: AuthStatus /** The existing auth token found in the editor config, if any */ existingToken?: string + /** + * Whether the Sanity agent skill is already installed globally for this + * editor's skills-CLI agent. Populated during setup classification when + * skill state has been probed; absent when skill installation isn't being + * considered (e.g. `mcp configure`). + */ + skillInstalled?: boolean } diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/readSkillState.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/readSkillState.test.ts new file mode 100644 index 000000000..a8596ce90 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/__tests__/readSkillState.test.ts @@ -0,0 +1,90 @@ +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {readSkillState} from '../readSkillState.js' +import {SKILLS_BIN_PATH} from '../setupSkills.js' + +const mockExeca = vi.hoisted(() => vi.fn()) + +vi.mock('execa', () => ({ + execa: mockExeca, +})) + +const SKILL = 'sanity-best-practices' + +describe('readSkillState', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('returns the agents reported by `skills list -g --json`', async () => { + mockExeca.mockResolvedValue({ + stdout: JSON.stringify([ + { + agents: ['Cursor', 'Claude Code'], + name: SKILL, + path: '/home/u/.cursor/skills/sanity-best-practices', + scope: 'global', + }, + ]), + }) + + const result = await readSkillState({skillName: SKILL}) + + expect(mockExeca).toHaveBeenCalledWith( + process.execPath, + [SKILLS_BIN_PATH, 'list', '-g', '--json'], + expect.objectContaining({stdio: 'pipe', timeout: 10_000}), + ) + expect([...result.installedAgentDisplayNames]).toEqual(['Cursor', 'Claude Code']) + }) + + test('returns an empty Set when the named skill is not present', async () => { + mockExeca.mockResolvedValue({ + stdout: JSON.stringify([{agents: ['Cursor'], name: 'something-else'}]), + }) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) + + test('returns an empty Set when the skill entry has no agents array', async () => { + mockExeca.mockResolvedValue({stdout: JSON.stringify([{name: SKILL}])}) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) + + test('returns an empty Set when the list is empty', async () => { + mockExeca.mockResolvedValue({stdout: '[]'}) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) + + test('returns an empty Set when JSON is malformed', async () => { + mockExeca.mockResolvedValue({stdout: '{not json'}) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) + + test('returns an empty Set when the JSON root is not an array', async () => { + mockExeca.mockResolvedValue({stdout: JSON.stringify({wrong: 'shape'})}) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) + + test('returns an empty Set when the subprocess fails', async () => { + mockExeca.mockRejectedValue(new Error('boom')) + + const result = await readSkillState({skillName: SKILL}) + + expect(result.installedAgentDisplayNames.size).toBe(0) + }) +}) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts new file mode 100644 index 000000000..e460dc5f0 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -0,0 +1,118 @@ +import {afterEach, describe, expect, test, vi} from 'vitest' + +import { + SANITY_SKILL_NAME, + SANITY_SKILLS_REPO, + setupSkills, + SKILLS_BIN_PATH, +} from '../setupSkills.js' + +const mockExeca = vi.hoisted(() => vi.fn()) + +vi.mock('execa', () => ({ + execa: mockExeca, +})) + +describe('setupSkills', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('returns skipped when no agents are passed', async () => { + const result = await setupSkills({agents: []}) + + expect(result).toEqual({installedAgents: [], skipped: true}) + expect(mockExeca).not.toHaveBeenCalled() + }) + + test('installs globally for a single agent', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) + + const result = await setupSkills({agents: ['cursor']}) + + expect(mockExeca).toHaveBeenCalledWith( + process.execPath, + [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--skill', + SANITY_SKILL_NAME, + '-g', + '-a', + 'cursor', + '-y', + ], + expect.objectContaining({stdio: 'pipe', timeout: 90_000}), + ) + expect(result).toEqual({installedAgents: ['cursor'], skipped: false}) + }) + + test('deduplicates repeated agent IDs', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) + + const result = await setupSkills({agents: ['cline', 'cline', 'cursor']}) + + expect(result.installedAgents).toEqual(['cline', 'cursor']) + expect(mockExeca).toHaveBeenCalledWith( + process.execPath, + [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--skill', + SANITY_SKILL_NAME, + '-g', + '-a', + 'cline', + '-a', + 'cursor', + '-y', + ], + expect.any(Object), + ) + }) + + test('passes every agent as a separate -a flag', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) + + await setupSkills({agents: ['cursor', 'claude-code', 'github-copilot']}) + + expect(mockExeca).toHaveBeenCalledWith( + process.execPath, + [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--skill', + SANITY_SKILL_NAME, + '-g', + '-a', + 'cursor', + '-a', + 'claude-code', + '-a', + 'github-copilot', + '-y', + ], + expect.any(Object), + ) + }) + + test('returns an error result when the skills CLI fails (does not throw)', async () => { + const installErr = new Error('skills exited 1') + mockExeca.mockRejectedValue(installErr) + + const result = await setupSkills({agents: ['cursor']}) + + expect(result.skipped).toBe(false) + expect(result.installedAgents).toEqual([]) + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.message).toBe('skills exited 1') + }) + + test('resolves SKILLS_BIN_PATH to a path that points at the bundled cli', () => { + // Accept both POSIX and Windows path separators + expect(SKILLS_BIN_PATH).toMatch(/skills[\\/]bin[\\/]cli\.mjs$/) + }) +}) diff --git a/packages/@sanity/cli/src/actions/skills/readSkillState.ts b/packages/@sanity/cli/src/actions/skills/readSkillState.ts new file mode 100644 index 000000000..2d2d64535 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/readSkillState.ts @@ -0,0 +1,67 @@ +import {subdebug} from '@sanity/cli-core' +import {execa} from 'execa' + +import {SKILLS_BIN_PATH} from './setupSkills.js' + +const debug = subdebug('skills:state') + +interface ReadSkillStateOptions { + /** Name of the skill to look up (matches the `name` field in `skills list --json`). */ + skillName: string +} + +interface SkillState { + /** Display names of agents that have this skill installed globally. */ + installedAgentDisplayNames: Set +} + +interface SkillListEntry { + agents?: unknown + name?: unknown +} + +/** + * Runs the bundled `skills list -g --json` and returns the set of agent + * display names that have `skillName` installed globally. + * + * Any failure (spawn, parse, timeout, non-zero exit) is debug-logged and + * resolved with an empty set. Callers should treat that as "treat all agents + * as not installed" — re-installing is idempotent, so over-installing is + * safer than skipping based on a flaky probe. + */ +export async function readSkillState(opts: ReadSkillStateOptions): Promise { + const empty: SkillState = {installedAgentDisplayNames: new Set()} + + let stdout: string + try { + const result = await execa(process.execPath, [SKILLS_BIN_PATH, 'list', '-g', '--json'], { + stdio: 'pipe', + timeout: 10_000, + }) + stdout = result.stdout + } catch (error) { + debug('skills list failed: %O', error) + return empty + } + + let parsed: unknown + try { + parsed = JSON.parse(stdout) + } catch (error) { + debug('Failed to parse skills list JSON: %O', error) + return empty + } + + if (!Array.isArray(parsed)) { + debug('Unexpected skills list JSON shape (not an array)') + return empty + } + + const match = (parsed as SkillListEntry[]).find((entry) => entry?.name === opts.skillName) + if (!match || !Array.isArray(match.agents)) { + return empty + } + + const displayNames = match.agents.filter((a): a is string => typeof a === 'string') + return {installedAgentDisplayNames: new Set(displayNames)} +} diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts new file mode 100644 index 000000000..e69a3549f --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -0,0 +1,83 @@ +import {fileURLToPath} from 'node:url' + +import {ux} from '@oclif/core' +import {subdebug} from '@sanity/cli-core' +import {logSymbols} from '@sanity/cli-core/ux' +import {execa} from 'execa' + +import {getErrorMessage, toError} from '../../util/getErrorMessage.js' + +const skillsDebug = subdebug('skills:setup') + +/** Source repo for the bundled `skills` CLI. See https://www.sanity.io/docs/ai/skills. */ +export const SANITY_SKILLS_REPO = 'sanity-io/agent-toolkit' + +/** Name of the skill we install — must match the entry in the source repo. */ +export const SANITY_SKILL_NAME = 'sanity-best-practices' + +/** + * Absolute path to the bundled `skills` CLI bin. Resolved once at module load + * via `import.meta.resolve` so we run the version pinned in our package.json + * instead of paying the `npx -y` registry lookup at runtime. + */ +export const SKILLS_BIN_PATH = fileURLToPath( + import.meta.resolve('skills/bin/cli.mjs', import.meta.url), +) + +interface SetupSkillsOptions { + /** Skills-CLI agent IDs (e.g. 'cursor', 'claude-code') to install for. */ + agents: string[] +} + +interface SetupSkillsResult { + /** Deduplicated `--agent` values passed to `skills add`. */ + installedAgents: string[] + skipped: boolean + + error?: Error +} + +/** + * Runs the bundled `skills add` globally for the given agents. Failures are + * surfaced as warnings and never throw — skills install is best-effort and + * must not abort `sanity init`. + */ +export async function setupSkills(options: SetupSkillsOptions): Promise { + const uniqueAgents = [...new Set(options.agents)] + + if (uniqueAgents.length === 0) { + skillsDebug('No agents passed — skipping skills install') + return {installedAgents: [], skipped: true} + } + + const args = [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--skill', + SANITY_SKILL_NAME, + '-g', + ...uniqueAgents.flatMap((agent) => ['-a', agent]), + '-y', + ] + + skillsDebug('Running: %s %s', process.execPath, args.join(' ')) + + try { + const result = await execa(process.execPath, args, {stdio: 'pipe', timeout: 90_000}) + skillsDebug('skills stdout: %s', result.stdout) + skillsDebug('skills stderr: %s', result.stderr) + ux.stdout(`${logSymbols.success} Installed Sanity agent skills for ${uniqueAgents.join(', ')}`) + return {installedAgents: uniqueAgents, skipped: false} + } catch (error) { + skillsDebug('Error installing skills %O', error) + const err = toError(error) + ux.warn(`Could not install Sanity agent skills: ${getErrorMessage(error)}`) + if (error && typeof error === 'object') { + const {stderr, stdout} = error as {stderr?: string; stdout?: string} + if (stdout) ux.warn(stdout) + if (stderr) ux.warn(stderr) + } + return {error: err, installedAgents: [], skipped: false} + } +} diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts index f0e6c6803..f2c2c390e 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts @@ -76,10 +76,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts index 3f0a49e3f..9f9d9b1fc 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ installDeclaredPackages: vi.fn(), select: vi.fn(), setupMCP: vi.fn(), + setupSkills: vi.fn(), })) vi.mock('@sanity/cli-core', async (importOriginal) => { @@ -91,6 +92,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + skillsToInstall: ['cursor'], + skipped: false, + }), +})) + +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: mocks.setupSkills.mockResolvedValue({ + installedAgents: ['cursor'], skipped: false, }), })) @@ -207,6 +220,13 @@ describe('#init: bootstrap-app-initialization', () => { expect(stdout).toContain('npx sanity docs browse') expect(stdout).toContain('npx sanity manage') expect(stdout).toContain('npx sanity help') + + // Skills install runs after scaffolding has completed, with the agents + // setupMCP told us to install. + expect(mocks.setupSkills).toHaveBeenCalledWith({agents: ['cursor']}) + const bootstrapOrder = mocks.bootstrapTemplate.mock.invocationCallOrder[0] + const skillsOrder = mocks.setupSkills.mock.invocationCallOrder[0] + expect(skillsOrder).toBeGreaterThan(bootstrapOrder) }) test('initializes app with env file', async () => { diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts index 6a33cac14..86a92223b 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts @@ -83,6 +83,80 @@ describe('mcpMode resolution', () => { }) }) +describe('skillsMode resolution', () => { + afterEach(() => { + vi.clearAllMocks() + mockIsInteractive.mockReturnValue(true) + }) + + test('sets skillsMode to "auto" by default (interactive)', async () => { + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({skillsMode: 'auto'}), + expect.any(Object), + ) + }) + + test('sets skillsMode to "skip" when --no-skills is passed', async () => { + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, ['--no-skills'], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({skillsMode: 'skip'}), + expect.any(Object), + ) + }) + + test('sets skillsMode to "skip" when not interactive (CI)', async () => { + mockIsInteractive.mockReturnValue(false) + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: false, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({skillsMode: 'skip'}), + expect.any(Object), + ) + }) + + test('sets skillsMode to "skip" in non-production Sanity env (e2e / UI tests)', async () => { + const previous = process.env.SANITY_INTERNAL_ENV + process.env.SANITY_INTERNAL_ENV = 'staging' + mockInitAction.mockResolvedValue(undefined) + + try { + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({skillsMode: 'skip'}), + expect.any(Object), + ) + } finally { + if (previous === undefined) { + delete process.env.SANITY_INTERNAL_ENV + } else { + process.env.SANITY_INTERNAL_ENV = previous + } + } + }) +}) + describe('error handling', () => { afterEach(() => { vi.clearAllMocks() diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts index 7aae59741..bcd74a6a2 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts @@ -109,10 +109,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) @@ -350,6 +362,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code'], configuredEditors: [], detectedEditors: ['VS Code'], + skillsToInstall: [], skipped: true, }) @@ -401,6 +414,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code', 'Cursor'], configuredEditors: [], detectedEditors: ['VS Code', 'Cursor'], + skillsToInstall: [], skipped: true, }) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts index e7852fcd9..847421442 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts @@ -89,10 +89,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts index 3e408cd0f..c6cce2d5b 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts @@ -112,10 +112,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: mocks.checkNextJsReactCompatibility.mockResolvedValue(undefined), })) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.plan.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.plan.test.ts index 34113f3ef..9b529e74b 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.plan.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.plan.test.ts @@ -88,10 +88,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.setup.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.setup.test.ts index e2f700b91..304067645 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.setup.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.setup.test.ts @@ -309,6 +309,7 @@ const baseOptions = { datasetDefault: false, fromCreate: false, mcpMode: 'skip' as const, + skillsMode: 'skip' as const, template: 'clean', unattended: true, } diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts index 0b3c0752a..decbf2eae 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts @@ -3,6 +3,7 @@ import {cleanAll, pendingMocks} from 'nock' import {afterEach, describe, expect, test, vi} from 'vitest' import {setupMCP} from '../../../actions/mcp/setupMCP.js' +import {setupSkills} from '../../../actions/skills/setupSkills.js' import {PROJECT_FEATURES_API_VERSION} from '../../../services/getProjectFeatures.js' import {MCP_JOURNEY_API_VERSION} from '../../../services/mcp.js' import {PROJECTS_API_VERSION} from '../../../services/projects.js' @@ -91,10 +92,22 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + skipped: true, + }), +})) + vi.mock('../../../util/packageManager/installPackages.js', () => ({ installDeclaredPackages: mocks.installDeclaredPackages.mockResolvedValue(undefined), })) @@ -209,6 +222,44 @@ describe('#init: staging env propagation', () => { }) }) + test('installs agent skills on the --env flag path when MCP is configured', async () => { + mocks.getSanityEnv.mockReturnValue('staging') + + // MCP setup populates skillsToInstall for detected editors regardless of the + // exit path, so the --env early-return must still install them. + vi.mocked(setupMCP).mockResolvedValueOnce({ + alreadyConfiguredEditors: [], + configuredEditors: ['Claude Code'], + detectedEditors: [], + error: undefined, + skillsToInstall: ['claude-code'], + skipped: false, + }) + + // The --env flag path exits early, so only features are needed + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const {error} = await testCommand( + InitCommand, + ['--output-path=/test/output', '--project=test', '--dataset=test', '--env=.env'], + { + mocks: { + ...defaultMocks, + isInteractive: true, + }, + }, + ) + + if (error) throw error + + expect(setupSkills).toHaveBeenCalledTimes(1) + expect(setupSkills).toHaveBeenCalledWith({agents: ['claude-code']}) + }) + test('writes SANITY_INTERNAL_ENV to .env when in staging (template bootstrap path)', async () => { mocks.getSanityEnv.mockReturnValue('staging') setupInitSuccessMocks() @@ -298,6 +349,6 @@ describe('#init: staging env propagation', () => { }, ) - expect(setupMCP).toHaveBeenCalledWith({mode: 'skip'}) + expect(setupMCP).toHaveBeenCalledWith({editors: [], mode: 'skip', skillsMode: 'skip'}) }) }) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index ca539ef9f..51b441d01 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -179,6 +179,11 @@ export class InitCommand extends SanityCommand { description: 'Reconfigure an existing project', hidden: true, }), + skills: Flags.boolean({ + allowNo: true, + default: true, + description: 'Install Sanity agent skills globally for detected AI editors', + }), template: Flags.string({ description: 'Project template to use [default: "clean"]', exclusive: ['bare'], @@ -217,12 +222,22 @@ export class InitCommand extends SanityCommand { mcpMode = 'auto' } + // Mirror MCP's environment gating: skip skills install in non-production + // Sanity envs so e2e / UI tests don't run the bundled skills CLI. + let skillsMode: 'auto' | 'prompt' | 'skip' = 'auto' + if (!this.flags.skills || !this.resolveIsInteractive() || getSanityEnv() !== 'production') { + skillsMode = 'skip' + } + try { - await initAction(flagsToInitOptions(this.flags, this.isUnattended(), this.args, mcpMode), { - output: this.output, - telemetry: this.telemetry, - workDir: process.cwd(), - }) + await initAction( + flagsToInitOptions(this.flags, this.isUnattended(), this.args, mcpMode, skillsMode), + { + output: this.output, + telemetry: this.telemetry, + workDir: process.cwd(), + }, + ) } catch (error) { if (error instanceof InitError) { this.error(error.message, {exit: error.exitCode}) diff --git a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts index b56375eeb..0056bd2a2 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -2,16 +2,17 @@ import {existsSync, type PathLike} from 'node:fs' import fs from 'node:fs/promises' import {checkbox} from '@sanity/cli-core/ux' -import {convertToSystemPath, createTestToken, mockApi, testCommand} from '@sanity/cli-test' +import {convertToSystemPath, createTestToken, testCommand} from '@sanity/cli-test' import {execa} from 'execa' import {cleanAll, pendingMocks} from 'nock' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' -import {MCP_API_VERSION} from '../../../services/mcp.js' import {ConfigureMcpCommand} from '../configure.js' +const mockCreateMCPToken = vi.hoisted(() => vi.fn()) const mockEnsureAuthenticated = vi.hoisted(() => vi.fn()) const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) +const mockValidateMCPToken = vi.hoisted(() => vi.fn()) vi.mock('../../../actions/auth/ensureAuthenticated.js', async (importOriginal) => { const actual = @@ -30,6 +31,15 @@ vi.mock('@sanity/cli-core', async (importOriginal) => { } }) +vi.mock('../../../services/mcp.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createMCPToken: mockCreateMCPToken, + validateMCPToken: mockValidateMCPToken, + } +}) + vi.mock('@sanity/cli-core/ux', async () => { const actual = await vi.importActual('@sanity/cli-core/ux') return { @@ -64,6 +74,10 @@ const mockReadFile = vi.mocked(fs.readFile) const mockWriteFile = vi.mocked(fs.writeFile) const mockExeca = vi.mocked(execa) +function mockMCPTokenCreation(token: string): void { + mockCreateMCPToken.mockResolvedValueOnce(token) +} + // --------------------------------------------------------------------------- // Helpers for table-driven per-editor tests // --------------------------------------------------------------------------- @@ -140,22 +154,10 @@ async function runEditorTest(tc: EditorTestCase): Promise { mockCheckbox.mockResolvedValue([tc.name]) - const sessionId = `session-${tc.name.toLowerCase().replaceAll(/\s+/g, '-')}` const token = `test-token-${tc.name.toLowerCase().replaceAll(/\s+/g, '-')}` if (!tc.oauthOnly) { - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(200, {id: sessionId, sid: sessionId}) - - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'get', - query: {sid: sessionId}, - uri: '/auth/fetch', - }).reply(200, {label: 'MCP Token', token}) + mockMCPTokenCreation(token) } const {stdout} = await testCommand(ConfigureMcpCommand, []) @@ -408,7 +410,7 @@ const mcporterTestCases: Array<{ // Main test suite // --------------------------------------------------------------------------- -describe('#mcp:configure', () => { +describe.sequential('#mcp:configure', () => { beforeEach(async () => { mockEnsureAuthenticated.mockResolvedValue({ email: 'test@example.com', @@ -417,10 +419,12 @@ describe('#mcp:configure', () => { provider: 'github', }) mockExistsSync.mockReturnValue(false) - mockReadFile.mockResolvedValue('{}') // Default: empty config file + mockReadFile.mockResolvedValue('{}') mockWriteFile.mockResolvedValue() mockExeca.mockRejectedValue(new Error('Not installed')) - createTestToken('test-token') + mockCreateMCPToken.mockReset() + mockValidateMCPToken.mockReset() + createTestToken('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature') }) afterEach(() => { @@ -516,11 +520,7 @@ describe('#mcp:configure', () => { }), ) - // Token validation succeeds against Sanity API - mockApi({apiVersion: MCP_API_VERSION, method: 'get', uri: '/users/me'}).reply(200, { - id: 'user-123', - name: 'Test User', - }) + mockValidateMCPToken.mockResolvedValueOnce(true) const {stdout} = await testCommand(ConfigureMcpCommand, []) @@ -573,12 +573,7 @@ describe('#mcp:configure', () => { }), ) - // Token validation fails against Sanity API (dead token) - mockApi({apiVersion: MCP_API_VERSION, method: 'get', uri: '/users/me'}).reply(401, { - error: 'Unauthorized', - message: 'Invalid token', - statusCode: 401, - }) + mockValidateMCPToken.mockResolvedValueOnce(false) mockCheckbox.mockResolvedValue(['Cursor']) @@ -621,11 +616,7 @@ describe('#mcp:configure', () => { return '{}' }) - // Token validation succeeds against Sanity API - mockApi({apiVersion: MCP_API_VERSION, method: 'get', uri: '/users/me'}).reply(200, { - id: 'user-123', - name: 'Test User', - }) + mockValidateMCPToken.mockResolvedValueOnce(true) // User selects only the unconfigured editor (Gemini CLI) mockCheckbox.mockResolvedValue(['Gemini CLI']) @@ -666,18 +657,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['Cursor', 'VS Code']) - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(200, {id: 'session-multi', sid: 'session-multi'}) - - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'get', - query: {sid: 'session-multi'}, - uri: '/auth/fetch', - }).reply(200, {label: 'MCP Token', token: 'multi-token-123'}) + mockMCPTokenCreation('multi-token-123') const {stdout} = await testCommand(ConfigureMcpCommand, []) @@ -705,18 +685,7 @@ describe('#mcp:configure', () => { throw new Error('Not installed') }) as never) - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(200, {id: 'session-ci', sid: 'session-ci'}) - - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'get', - query: {sid: 'session-ci'}, - uri: '/auth/fetch', - }).reply(200, {label: 'MCP Token', token: 'test-token-ci'}) + mockMCPTokenCreation('test-token-ci') const {stdout} = await testCommand(ConfigureMcpCommand, []) @@ -741,11 +710,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['OpenCode']) - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(401, {message: 'Not authenticated'}) + mockCreateMCPToken.mockRejectedValueOnce(new Error('Not authenticated')) const {stderr} = await testCommand(ConfigureMcpCommand, []) @@ -762,18 +727,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['OpenCode']) - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(200, {id: 'session-write-error', sid: 'session-write-error'}) - - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'get', - query: {sid: 'session-write-error'}, - uri: '/auth/fetch', - }).reply(200, {label: 'MCP Token', token: 'token-write-error'}) + mockMCPTokenCreation('token-write-error') mockWriteFile.mockRejectedValue(new Error('Permission denied')) @@ -832,18 +786,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['OpenCode']) - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'post', - uri: '/auth/session/create', - }).reply(200, {id: 'session-merge', sid: 'session-merge'}) - - mockApi({ - apiVersion: MCP_API_VERSION, - method: 'get', - query: {sid: 'session-merge'}, - uri: '/auth/fetch', - }).reply(200, {label: 'MCP Token', token: 'merge-token-123'}) + mockMCPTokenCreation('merge-token-123') await testCommand(ConfigureMcpCommand, []) diff --git a/packages/@sanity/cli/src/commands/mcp/configure.ts b/packages/@sanity/cli/src/commands/mcp/configure.ts index 41959cead..9881f348e 100644 --- a/packages/@sanity/cli/src/commands/mcp/configure.ts +++ b/packages/@sanity/cli/src/commands/mcp/configure.ts @@ -40,7 +40,11 @@ export class ConfigureMcpCommand extends SanityCommand=14'} + '@vercel/edge@1.2.2': resolution: {integrity: sha512-1+y+f6rk0Yc9ss9bRDgz/gdpLimwoRteKHhrcgHvEpjbP1nyT3ByqEMWm2BTcpIO5UtDmIFXc8zdq4LR190PDA==} @@ -8957,6 +8967,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + skills@1.5.7: + resolution: {integrity: sha512-Df/2bXm/5Glp7o6n2Ft5v5EmowaD0x8lbvDwionoIhS1sxnWTy5KwMsMPlu3fqhfIZj95m7CC4p+jNVkXNQGYQ==} + engines: {node: '>=18'} + hasBin: true + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -15150,6 +15165,8 @@ snapshots: - babel-plugin-macros - supports-color + '@vercel/detect-agent@1.2.3': {} + '@vercel/edge@1.2.2': {} '@vercel/error-utils@2.0.3': {} @@ -19030,6 +19047,10 @@ snapshots: sisteransi@1.0.5: {} + skills@1.5.7: + dependencies: + yaml: 2.8.4 + slash@3.0.0: {} slash@5.1.0: {} diff --git a/vitest.config.ts b/vitest.config.ts index 81ee93964..7740d16ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,9 @@ import {createHash} from 'node:crypto' +import {determineAgent} from '@vercel/detect-agent' import {defineConfig} from 'vitest/config' -const IS_AGENT = Boolean(process.env.CLAUDECODE || process.env.CODEX_CI) +const {isAgent: IS_AGENT} = await determineAgent() const cwdHash = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8) const OUTPUT_FILE = IS_AGENT ? {json: `/tmp/test-results-${cwdHash}.json`} : undefined