From b245dbcec687799289b58e8af8d180432c98bf53 Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 12:18:41 -0700 Subject: [PATCH 01/15] feat: configure agent skills as part of `sanity init` Signed-off-by: Jonah Snider --- .../cli/src/actions/init/initAction.ts | 1 + .../actions/mcp/__tests__/setupMCP.test.ts | 63 ++++++++++- .../cli/src/actions/mcp/editorConfigs.ts | 39 ++++++- .../cli/src/actions/mcp/promptForMCPSetup.ts | 2 +- .../@sanity/cli/src/actions/mcp/setupMCP.ts | 18 +++ .../skills/__tests__/setupSkills.test.ts | 107 ++++++++++++++++++ .../cli/src/actions/skills/setupSkills.ts | 90 +++++++++++++++ .../init/init.authentication.test.ts | 1 + .../__tests__/init/init.bootstrap-app.test.ts | 1 + .../init/init.create-new-project.test.ts | 3 + .../init/init.get-project-details.test.ts | 1 + .../__tests__/init/init.nextjs.test.ts | 1 + .../commands/__tests__/init/init.plan.test.ts | 1 + .../__tests__/init/init.staging-env.test.ts | 1 + .../commands/mcp/__tests__/configure.test.ts | 18 ++- .../@sanity/cli/src/commands/mcp/configure.ts | 1 + .../cli/src/telemetry/init.telemetry.ts | 1 + .../cli/src/telemetry/mcp.telemetry.ts | 1 + 18 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts create mode 100644 packages/@sanity/cli/src/actions/skills/setupSkills.ts diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index a1fd41b74..535ad522e 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -192,6 +192,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr trace.log({ configuredEditors: mcpResult.configuredEditors, detectedEditors: mcpResult.detectedEditors, + installedSkillsCliAgents: mcpResult.installedSkillsCliAgents, skipped: mcpResult.skipped, step: 'mcpSetup', }) 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..0d500db53 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -1,4 +1,4 @@ -import {afterEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {setupMCP} from '../setupMCP.js' @@ -7,6 +7,7 @@ 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 mockSetupSkills = vi.hoisted(() => vi.fn()) vi.mock('../detectAvailableEditors.js', () => ({ detectAvailableEditors: mockDetectAvailableEditors, @@ -29,11 +30,20 @@ vi.mock('../writeMCPConfig.js', () => ({ writeMCPConfig: mockWriteMCPConfig, })) +vi.mock('../../skills/setupSkills.js', () => ({ + setupSkills: mockSetupSkills, +})) + describe('setupMCP', () => { afterEach(() => { vi.clearAllMocks() }) + // Default skills mock to a no-op success so existing tests don't have to set it + beforeEach(() => { + mockSetupSkills.mockResolvedValue({installedAgents: [], skipped: true}) + }) + test('mode: skip returns early without detecting editors', async () => { const result = await setupMCP({mode: 'skip'}) @@ -100,4 +110,55 @@ describe('setupMCP', () => { expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) }) + + test('invokes setupSkills with the configured editors after a successful MCP setup', async () => { + const editors = [ + {authStatus: 'unknown', configured: false, name: 'Cursor'}, + {authStatus: 'unknown', configured: false, name: 'Claude Code'}, + ] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockCreateMCPToken.mockResolvedValue('test-token') + mockWriteMCPConfig.mockResolvedValue(undefined) + mockSetupSkills.mockResolvedValue({ + installedAgents: ['cursor', 'claude-code'], + skipped: false, + }) + + const result = await setupMCP({mode: 'auto'}) + + expect(mockSetupSkills).toHaveBeenCalledTimes(1) + expect(mockSetupSkills).toHaveBeenCalledWith({editors}) + expect(result.installedSkillsCliAgents).toEqual(['cursor', 'claude-code']) + expect(result.skillsError).toBeUndefined() + }) + + test('surfaces skills install error without failing MCP setup', async () => { + const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockCreateMCPToken.mockResolvedValue('test-token') + mockWriteMCPConfig.mockResolvedValue(undefined) + const skillsError = new Error('skills install failed') + mockSetupSkills.mockResolvedValue({ + error: skillsError, + installedAgents: [], + skipped: false, + }) + + const result = await setupMCP({mode: 'auto'}) + + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skipped).toBe(false) + expect(result.installedSkillsCliAgents).toEqual([]) + expect(result.skillsError).toBe(skillsError) + }) + + test('does not invoke setupSkills when no editors are configured', async () => { + mockDetectAvailableEditors.mockResolvedValue([]) + + await setupMCP({mode: 'auto'}) + + expect(mockSetupSkills).not.toHaveBeenCalled() + }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts index 8038ff82f..f56179348 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,26 @@ 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}, + 'Claude Code': {...EDITOR_DEFAULTS, detect: detectClaudeCode, 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 +330,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 +338,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 +361,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, diff --git a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts index f01aae84a..72d549517 100644 --- a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts +++ b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts @@ -27,7 +27,7 @@ export async function promptForMCPSetup(editors: Editor[]): Promise configuredEditors.includes(editor.name)), + }) + return { alreadyConfiguredEditors, configuredEditors, detectedEditors, + installedSkillsCliAgents: skillsResult.installedAgents, + skillsError: skillsResult.error, skipped: false, } } 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..edf1eb375 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -0,0 +1,107 @@ +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {type Editor} from '../../mcp/types.js' +import {SANITY_SKILLS_REPO, setupSkills} from '../setupSkills.js' + +const mockExeca = vi.hoisted(() => vi.fn()) + +vi.mock('execa', () => ({ + execa: mockExeca, +})) + +function editor(name: Editor['name']): Editor { + return {configPath: `/tmp/${name}.json`, configured: false, name} +} + +describe('setupSkills', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('mode: skip returns early without calling npx', async () => { + const result = await setupSkills({editors: [editor('Cursor')], mode: 'skip'}) + + expect(result).toEqual({installedAgents: [], skipped: true}) + expect(mockExeca).not.toHaveBeenCalled() + }) + + test('skips when no editors have a skills agent mapping', async () => { + // Zed and MCPorter do not have a skillsAgent mapping + const result = await setupSkills({editors: [editor('Zed'), editor('MCPorter')]}) + + expect(result).toEqual({installedAgents: [], skipped: true}) + expect(mockExeca).not.toHaveBeenCalled() + }) + + test('installs skills for mapped agents via npx', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + const result = await setupSkills({ + editors: [editor('Cursor'), editor('Claude Code')], + }) + + expect(mockExeca).toHaveBeenCalledTimes(1) + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], + expect.objectContaining({stdio: 'inherit'}), + ) + expect(result.installedAgents).toEqual(['cursor', 'claude-code']) + expect(result.skipped).toBe(false) + expect(result.error).toBeUndefined() + }) + + test('deduplicates agents (e.g. Cline and Cline CLI map to the same agent)', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + const result = await setupSkills({ + editors: [editor('Cline'), editor('Cline CLI')], + }) + + expect(result.installedAgents).toEqual(['cline']) + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cline', '-y'], + expect.any(Object), + ) + }) + + test('filters out editors that have no skills agent before invoking npx', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + const result = await setupSkills({ + editors: [editor('Zed'), editor('Cursor'), editor('MCPorter')], + }) + + expect(result.installedAgents).toEqual(['cursor']) + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-y'], + expect.any(Object), + ) + }) + + test('returns an error result when npx fails (does not throw)', async () => { + const installErr = new Error('npx exited 1') + mockExeca.mockRejectedValue(installErr) + + const result = await setupSkills({editors: [editor('Cursor')]}) + + expect(result.skipped).toBe(false) + expect(result.installedAgents).toEqual([]) + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.message).toBe('npx exited 1') + }) + + test('VS Code maps to github-copilot agent', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + await setupSkills({editors: [editor('VS Code'), editor('VS Code Insiders')]}) + + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'github-copilot', '-y'], + expect.any(Object), + ) + }) +}) 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..c3fbf6699 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -0,0 +1,90 @@ +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' +import {EDITOR_CONFIGS} from '../mcp/editorConfigs.js' +import {type Editor} from '../mcp/types.js' + +const skillsDebug = subdebug('skills:setup') + +/** + * GitHub repo containing the Sanity agent skills. Installed via `npx skills add`. + * + * Source: https://github.com/sanity-io/agent-toolkit (referenced from + * https://www.sanity.io/docs/ai/skills). + */ +export const SANITY_SKILLS_REPO = 'sanity-io/agent-toolkit' + +interface SetupSkillsOptions { + /** + * Editors that MCP was (or will be) configured for. Skills will be installed + * for each editor's mapped `skillsAgent`. + */ + editors: Editor[] + + /** + * Controls how skills setup behaves: + * - 'auto': Install for the editors' mapped agents (default) + * - 'skip': Skip skills installation entirely + */ + mode?: 'auto' | 'skip' +} + +interface SetupSkillsResult { + /** `--agent` values that were targeted by `npx skills add` */ + installedAgents: string[] + skipped: boolean + + error?: Error +} + +/** + * Install Sanity agent skills for the given editors using `npx skills add`. + * + * Failures are surfaced as warnings and do not throw — skills install is + * best-effort and should never abort MCP setup or `sanity init`. + */ +export async function setupSkills(options: SetupSkillsOptions): Promise { + const {editors, mode = 'auto'} = options + + if (mode === 'skip') { + skillsDebug('Skipping skills installation (mode: skip)') + return {installedAgents: [], skipped: true} + } + + const agents = editors.flatMap((editor) => { + const agent = (EDITOR_CONFIGS[editor.name] as {skillsAgent?: string}).skillsAgent + return agent ? [agent] : [] + }) + + const uniqueAgents = [...new Set(agents)] + + if (uniqueAgents.length === 0) { + skillsDebug('No editors with a skills agent mapping — skipping') + return {installedAgents: [], skipped: true} + } + + const args = [ + '-y', + 'skills', + 'add', + SANITY_SKILLS_REPO, + ...uniqueAgents.flatMap((agent) => ['-a', agent]), + '-y', + ] + + skillsDebug('Running: npx %s', args.join(' ')) + + try { + await execa('npx', args, {stdio: 'inherit', timeout: 90_000}) + 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)}`) + 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..4dbe240d9 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,6 +76,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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..a011ef8d2 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 @@ -91,6 +91,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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..c6adc3131 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,6 +109,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) @@ -350,6 +351,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code'], configuredEditors: [], detectedEditors: ['VS Code'], + installedSkillsCliAgents: [], skipped: true, }) @@ -401,6 +403,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code', 'Cursor'], configuredEditors: [], detectedEditors: ['VS Code', 'Cursor'], + installedSkillsCliAgents: [], 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..2ac3e6325 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,6 +89,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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..86d513a8c 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,6 +112,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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..4ddc40c49 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,6 +88,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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..143a71c4f 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 @@ -91,6 +91,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + installedSkillsCliAgents: [], skipped: false, }), })) 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 cb49112d6..42526767f 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -58,6 +58,12 @@ vi.mock('execa', () => ({ execa: vi.fn(), })) +const mockSetupSkills = vi.hoisted(() => vi.fn()) +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + SANITY_SKILLS_REPO: 'sanity-io/agent-toolkit', + setupSkills: mockSetupSkills, +})) + const mockCheckbox = vi.mocked(checkbox) const mockExistsSync = vi.mocked(existsSync) const mockReadFile = vi.mocked(fs.readFile) @@ -419,6 +425,7 @@ describe('#mcp:configure', () => { mockReadFile.mockResolvedValue('{}') // Default: empty config file mockWriteFile.mockResolvedValue() mockExeca.mockRejectedValue(new Error('Not installed')) + mockSetupSkills.mockResolvedValue({installedAgents: [], skipped: true}) createTestToken('test-token') }) @@ -593,7 +600,7 @@ describe('#mcp:configure', () => { value: 'Cursor', }, ], - message: 'Configure Sanity MCP server?', + message: 'Configure Sanity MCP and agent skills for your editor?', }) }) @@ -694,6 +701,15 @@ describe('#mcp:configure', () => { ) expect(stdout).toContain('MCP configured for Cursor, VS Code') + + // Skills install was invoked for both selected editors + expect(mockSetupSkills).toHaveBeenCalledTimes(1) + expect(mockSetupSkills).toHaveBeenCalledWith({ + editors: expect.arrayContaining([ + expect.objectContaining({name: 'Cursor'}), + expect.objectContaining({name: 'VS Code'}), + ]), + }) }) test('auto-selects all editors in non-interactive mode without prompting', async () => { diff --git a/packages/@sanity/cli/src/commands/mcp/configure.ts b/packages/@sanity/cli/src/commands/mcp/configure.ts index 41959cead..a1d00fc2e 100644 --- a/packages/@sanity/cli/src/commands/mcp/configure.ts +++ b/packages/@sanity/cli/src/commands/mcp/configure.ts @@ -45,6 +45,7 @@ export class ConfigureMcpCommand extends SanityCommand({ From 05d838ad168c309097a3a696c0db8ebf7231331e Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 19:19:28 +0000 Subject: [PATCH 02/15] chore: update auto-generated changeset for PR #1079 --- .changeset/pr-1079.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-1079.md diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md new file mode 100644 index 000000000..11c1e76c2 --- /dev/null +++ b/.changeset/pr-1079.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli': minor +--- + +feat: configure agent skills as part of `sanity init` \ No newline at end of file From c5187cc9a43316f3862b874f657da366fba64c41 Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 12:23:27 -0700 Subject: [PATCH 03/15] fix: fix typo from incomplete refactor Signed-off-by: Jonah Snider --- .../cli/src/actions/skills/__tests__/setupSkills.test.ts | 2 +- packages/@sanity/cli/src/actions/skills/setupSkills.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index edf1eb375..44e385ce1 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -26,7 +26,7 @@ describe('setupSkills', () => { }) test('skips when no editors have a skills agent mapping', async () => { - // Zed and MCPorter do not have a skillsAgent mapping + // Zed and MCPorter do not have a skillsCliAgent mapping const result = await setupSkills({editors: [editor('Zed'), editor('MCPorter')]}) expect(result).toEqual({installedAgents: [], skipped: true}) diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index c3fbf6699..b5e3be691 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -20,7 +20,7 @@ export const SANITY_SKILLS_REPO = 'sanity-io/agent-toolkit' interface SetupSkillsOptions { /** * Editors that MCP was (or will be) configured for. Skills will be installed - * for each editor's mapped `skillsAgent`. + * for each editor's mapped `skillsCliAgent`. */ editors: Editor[] @@ -55,7 +55,7 @@ export async function setupSkills(options: SetupSkillsOptions): Promise { - const agent = (EDITOR_CONFIGS[editor.name] as {skillsAgent?: string}).skillsAgent + const agent = (EDITOR_CONFIGS[editor.name] as {skillsCliAgent?: string}).skillsCliAgent return agent ? [agent] : [] }) From 48e1548e597afb55882538a0c7eadf37646f43ce Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 13:29:11 -0700 Subject: [PATCH 04/15] fix: hide `npx skills` output Signed-off-by: Jonah Snider --- .../cli/src/actions/skills/__tests__/setupSkills.test.ts | 2 +- packages/@sanity/cli/src/actions/skills/setupSkills.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index 44e385ce1..059f9e2ad 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -44,7 +44,7 @@ describe('setupSkills', () => { expect(mockExeca).toHaveBeenCalledWith( 'npx', ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], - expect.objectContaining({stdio: 'inherit'}), + expect.objectContaining({stdio: 'pipe'}), ) expect(result.installedAgents).toEqual(['cursor', 'claude-code']) expect(result.skipped).toBe(false) diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index b5e3be691..14ade021d 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -78,13 +78,20 @@ export async function setupSkills(options: SetupSkillsOptions): Promise Date: Wed, 13 May 2026 13:34:57 -0700 Subject: [PATCH 05/15] fix: install skills to correct directory Signed-off-by: Jonah Snider --- .../cli/src/actions/init/initAction.ts | 2 +- .../actions/mcp/__tests__/setupMCP.test.ts | 26 ++++++++++--- .../@sanity/cli/src/actions/mcp/setupMCP.ts | 38 ++++++++++++++----- .../skills/__tests__/setupSkills.test.ts | 25 +++++++++--- .../cli/src/actions/skills/setupSkills.ts | 11 +++++- 5 files changed, 78 insertions(+), 24 deletions(-) diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index 535ad522e..b5eb7a282 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -187,7 +187,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr workDir, }) - const mcpResult = await setupMCP({mode: options.mcpMode}) + const mcpResult = await setupMCP({cwd: outputPath, mode: options.mcpMode}) trace.log({ configuredEditors: mcpResult.configuredEditors, 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 0d500db53..532ad6edb 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -111,7 +111,7 @@ describe('setupMCP', () => { expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) }) - test('invokes setupSkills with the configured editors after a successful MCP setup', async () => { + test('invokes setupSkills with the configured editors after a successful MCP setup when cwd is provided', async () => { const editors = [ {authStatus: 'unknown', configured: false, name: 'Cursor'}, {authStatus: 'unknown', configured: false, name: 'Claude Code'}, @@ -125,14 +125,28 @@ describe('setupMCP', () => { skipped: false, }) - const result = await setupMCP({mode: 'auto'}) + const result = await setupMCP({cwd: '/tmp/project', mode: 'auto'}) expect(mockSetupSkills).toHaveBeenCalledTimes(1) - expect(mockSetupSkills).toHaveBeenCalledWith({editors}) + expect(mockSetupSkills).toHaveBeenCalledWith({cwd: '/tmp/project', editors}) expect(result.installedSkillsCliAgents).toEqual(['cursor', 'claude-code']) expect(result.skillsError).toBeUndefined() }) + test('does not invoke setupSkills when cwd is not provided (e.g. sanity mcp configure)', async () => { + const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockCreateMCPToken.mockResolvedValue('test-token') + mockWriteMCPConfig.mockResolvedValue(undefined) + + const result = await setupMCP({mode: 'auto'}) + + expect(mockSetupSkills).not.toHaveBeenCalled() + expect(result.installedSkillsCliAgents).toEqual([]) + expect(result.skillsError).toBeUndefined() + }) + test('surfaces skills install error without failing MCP setup', async () => { const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] mockDetectAvailableEditors.mockResolvedValue(editors) @@ -146,7 +160,7 @@ describe('setupMCP', () => { skipped: false, }) - const result = await setupMCP({mode: 'auto'}) + const result = await setupMCP({cwd: '/tmp/project', mode: 'auto'}) expect(result.configuredEditors).toEqual(['Cursor']) expect(result.skipped).toBe(false) @@ -154,10 +168,10 @@ describe('setupMCP', () => { expect(result.skillsError).toBe(skillsError) }) - test('does not invoke setupSkills when no editors are configured', async () => { + test('does not invoke setupSkills when no editors are detected', async () => { mockDetectAvailableEditors.mockResolvedValue([]) - await setupMCP({mode: 'auto'}) + await setupMCP({cwd: '/tmp/project', mode: 'auto'}) expect(mockSetupSkills).not.toHaveBeenCalled() }) diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 981c6ac40..75990518c 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -15,6 +15,14 @@ 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.` interface MCPSetupOptions { + /** + * Working directory to install agent skills into. When provided, agent skills + * are installed into this directory after MCP is configured. When omitted + * (e.g. `sanity mcp configure`), skills installation is skipped — we don't + * want to write skill files into an arbitrary cwd like `~/dev`. + */ + cwd?: string + /** * Whether the user explicitly requested MCP configuration (e.g. `sanity mcp configure`). * When true, shows status messages even when there's nothing to do. @@ -50,7 +58,7 @@ interface MCPSetupResult { * Opt-out by default: runs automatically unless skip option is set */ export async function setupMCP(options?: MCPSetupOptions): Promise { - const {explicit = false, mode = 'prompt'} = options ?? {} + const {cwd, explicit = false, mode = 'prompt'} = options ?? {} // 1. Check for explicit opt-out if (mode === 'skip') { @@ -91,17 +99,24 @@ export async function setupMCP(options?: MCPSetupOptions): Promise e.configured && e.authStatus === 'valid') - .map((e) => e.name) + const alreadyConfiguredEditorObjects = editors.filter( + (e) => e.configured && e.authStatus === 'valid', + ) + const alreadyConfiguredEditors = alreadyConfiguredEditorObjects.map((e) => e.name) if (explicit) { ux.stdout(`${logSymbols.success} All detected editors are already configured`) } + + const skillsResult = cwd + ? await setupSkills({cwd, editors: alreadyConfiguredEditorObjects}) + : undefined + return { alreadyConfiguredEditors, configuredEditors: [], detectedEditors, - installedSkillsCliAgents: [], + installedSkillsCliAgents: skillsResult?.installedAgents ?? [], + skillsError: skillsResult?.error, skipped: true, } } @@ -182,16 +197,19 @@ export async function setupMCP(options?: MCPSetupOptions): Promise configuredEditors.includes(editor.name)), - }) + const skillsResult = cwd + ? await setupSkills({ + cwd, + editors: selected.filter((editor) => configuredEditors.includes(editor.name)), + }) + : undefined return { alreadyConfiguredEditors, configuredEditors, detectedEditors, - installedSkillsCliAgents: skillsResult.installedAgents, - skillsError: skillsResult.error, + installedSkillsCliAgents: skillsResult?.installedAgents ?? [], + skillsError: skillsResult?.error, skipped: false, } } diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index 059f9e2ad..69434a995 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -13,13 +13,19 @@ function editor(name: Editor['name']): Editor { return {configPath: `/tmp/${name}.json`, configured: false, name} } +const PROJECT_DIR = '/tmp/project' + describe('setupSkills', () => { afterEach(() => { vi.clearAllMocks() }) test('mode: skip returns early without calling npx', async () => { - const result = await setupSkills({editors: [editor('Cursor')], mode: 'skip'}) + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Cursor')], + mode: 'skip', + }) expect(result).toEqual({installedAgents: [], skipped: true}) expect(mockExeca).not.toHaveBeenCalled() @@ -27,7 +33,10 @@ describe('setupSkills', () => { test('skips when no editors have a skills agent mapping', async () => { // Zed and MCPorter do not have a skillsCliAgent mapping - const result = await setupSkills({editors: [editor('Zed'), editor('MCPorter')]}) + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Zed'), editor('MCPorter')], + }) expect(result).toEqual({installedAgents: [], skipped: true}) expect(mockExeca).not.toHaveBeenCalled() @@ -37,6 +46,7 @@ describe('setupSkills', () => { mockExeca.mockResolvedValue({exitCode: 0}) const result = await setupSkills({ + cwd: PROJECT_DIR, editors: [editor('Cursor'), editor('Claude Code')], }) @@ -44,7 +54,7 @@ describe('setupSkills', () => { expect(mockExeca).toHaveBeenCalledWith( 'npx', ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], - expect.objectContaining({stdio: 'pipe'}), + expect.objectContaining({cwd: PROJECT_DIR, stdio: 'pipe'}), ) expect(result.installedAgents).toEqual(['cursor', 'claude-code']) expect(result.skipped).toBe(false) @@ -55,6 +65,7 @@ describe('setupSkills', () => { mockExeca.mockResolvedValue({exitCode: 0}) const result = await setupSkills({ + cwd: PROJECT_DIR, editors: [editor('Cline'), editor('Cline CLI')], }) @@ -70,6 +81,7 @@ describe('setupSkills', () => { mockExeca.mockResolvedValue({exitCode: 0}) const result = await setupSkills({ + cwd: PROJECT_DIR, editors: [editor('Zed'), editor('Cursor'), editor('MCPorter')], }) @@ -85,7 +97,7 @@ describe('setupSkills', () => { const installErr = new Error('npx exited 1') mockExeca.mockRejectedValue(installErr) - const result = await setupSkills({editors: [editor('Cursor')]}) + const result = await setupSkills({cwd: PROJECT_DIR, editors: [editor('Cursor')]}) expect(result.skipped).toBe(false) expect(result.installedAgents).toEqual([]) @@ -96,7 +108,10 @@ describe('setupSkills', () => { test('VS Code maps to github-copilot agent', async () => { mockExeca.mockResolvedValue({exitCode: 0}) - await setupSkills({editors: [editor('VS Code'), editor('VS Code Insiders')]}) + await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('VS Code'), editor('VS Code Insiders')], + }) expect(mockExeca).toHaveBeenCalledWith( 'npx', diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index 14ade021d..dda1036b5 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -18,6 +18,13 @@ const skillsDebug = subdebug('skills:setup') export const SANITY_SKILLS_REPO = 'sanity-io/agent-toolkit' interface SetupSkillsOptions { + /** + * Working directory in which to run `npx skills add`. Required so skills are + * always written into a concrete project directory rather than wherever the + * user happened to invoke the CLI from (e.g. `~/dev`). + */ + cwd: string + /** * Editors that MCP was (or will be) configured for. Skills will be installed * for each editor's mapped `skillsCliAgent`. @@ -47,7 +54,7 @@ interface SetupSkillsResult { * best-effort and should never abort MCP setup or `sanity init`. */ export async function setupSkills(options: SetupSkillsOptions): Promise { - const {editors, mode = 'auto'} = options + const {cwd, editors, mode = 'auto'} = options if (mode === 'skip') { skillsDebug('Skipping skills installation (mode: skip)') @@ -78,7 +85,7 @@ export async function setupSkills(options: SetupSkillsOptions): Promise Date: Wed, 13 May 2026 14:13:03 -0700 Subject: [PATCH 06/15] fix: create project dir for `npx skills` if not exists Signed-off-by: Jonah Snider --- packages/@sanity/cli/src/actions/skills/setupSkills.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index dda1036b5..2ac8cf8af 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises' + import {ux} from '@oclif/core' import {subdebug} from '@sanity/cli-core' import {logSymbols} from '@sanity/cli-core/ux' @@ -82,9 +84,12 @@ export async function setupSkills(options: SetupSkillsOptions): Promise Date: Wed, 13 May 2026 14:13:25 -0700 Subject: [PATCH 07/15] fix: fix edge cases around MCP and skills configuration in init Signed-off-by: Jonah Snider --- .../actions/mcp/__tests__/setupMCP.test.ts | 54 +++++++++++++++++++ .../@sanity/cli/src/actions/mcp/setupMCP.ts | 53 ++++++++++-------- 2 files changed, 84 insertions(+), 23 deletions(-) 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 532ad6edb..9e4679f32 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -175,4 +175,58 @@ describe('setupMCP', () => { expect(mockSetupSkills).not.toHaveBeenCalled() }) + + test('still installs skills for already-MCP-configured editors during init (cwd provided)', async () => { + const editors = [ + { + authStatus: 'valid', + configured: true, + existingToken: 'tok', + name: 'Cursor', + }, + { + authStatus: 'valid', + configured: true, + existingToken: 'tok', + name: 'Claude Code', + }, + ] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockImplementation(async (eds) => eds) + mockSetupSkills.mockResolvedValue({ + installedAgents: ['cursor', 'claude-code'], + skipped: false, + }) + + const result = await setupMCP({cwd: '/tmp/project', mode: 'auto'}) + + // No MCP writes needed — already configured + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(mockCreateMCPToken).not.toHaveBeenCalled() + // But skills still installed for both editors + expect(mockSetupSkills).toHaveBeenCalledWith({cwd: '/tmp/project', editors}) + expect(result.installedSkillsCliAgents).toEqual(['cursor', 'claude-code']) + expect(result.configuredEditors).toEqual([]) + }) + + test('skips already-MCP-configured editors during sanity mcp configure (no cwd)', async () => { + const editors = [ + { + authStatus: 'valid', + configured: true, + existingToken: 'tok', + name: 'Cursor', + }, + ] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + + const result = await setupMCP({mode: 'auto'}) + + expect(mockSetupSkills).not.toHaveBeenCalled() + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 75990518c..55e22b4e2 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -95,10 +95,17 @@ export async function setupMCP(options?: MCPSetupOptions): Promise !e.configured || e.authStatus !== 'valid') + // An editor needs MCP setup if it's not configured yet or its credentials + // aren't valid. When a `cwd` is set (sanity init), every detected editor + // also needs skills installed into the new project — MCP being already + // configured globally doesn't imply project-local skills exist. + const needsMCPSetup = (e: (typeof editors)[number]) => !e.configured || e.authStatus !== 'valid' + const needsSkillsSetup = (e: (typeof editors)[number]) => + Boolean(cwd && (EDITOR_CONFIGS[e.name] as {skillsCliAgent?: string}).skillsCliAgent) + const actionable = editors.filter((e) => needsMCPSetup(e) || needsSkillsSetup(e)) if (actionable.length === 0) { - mcpDebug('All editors configured with valid credentials') + mcpDebug('All editors configured with valid credentials and no skills setup needed') const alreadyConfiguredEditorObjects = editors.filter( (e) => e.configured && e.authStatus === 'valid', ) @@ -107,21 +114,17 @@ 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 @@ -139,6 +142,10 @@ export async function setupMCP(options?: MCPSetupOptions): Promise needsMCPSetup(e)) + // 6. Get a token — reuse a valid existing one or create a new one let token: string | undefined @@ -149,11 +156,12 @@ export async function setupMCP(options?: MCPSetupOptions): Promise EDITOR_CONFIGS[e.name].oauthOnly) + const allOAuth = + selectedForMCP.length > 0 && selectedForMCP.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) { + // Fall back to creating a new token. Skip when no editors need MCP set up + // or when all of them use OAuth (no token required). + if (!token && !allOAuth && selectedForMCP.length > 0) { try { token = await createMCPToken() } catch (error) { @@ -172,10 +180,10 @@ export async function setupMCP(options?: MCPSetupOptions): Promise 0) { + ux.stdout(`${logSymbols.success} MCP configured for ${configuredEditors.join(', ')}`) + } - // 8. Install Sanity agent skills for the same editors (best-effort) - const skillsResult = cwd - ? await setupSkills({ - cwd, - editors: selected.filter((editor) => configuredEditors.includes(editor.name)), - }) - : undefined + // 8. Install Sanity agent skills for every selected editor (best-effort). + // This includes editors whose MCP was already configured — they may still + // be missing project-local skills. + const skillsResult = cwd ? await setupSkills({cwd, editors: selected}) : undefined return { alreadyConfiguredEditors, From f8d67c844ac7e3b1553be9604493c5bd25842f75 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:14:21 +0000 Subject: [PATCH 08/15] chore: update auto-generated changeset for PR #1079 --- .changeset/pr-1079.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index 11c1e76c2..74413ac57 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -1,6 +1,8 @@ --- +'@sanity/cli-build': minor '@sanity/cli': minor +'create-sanity': minor --- feat: configure agent skills as part of `sanity init` \ No newline at end of file From d0efb7253581b3e576ed6257478bb92553a35c57 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:15:43 +0000 Subject: [PATCH 09/15] chore: update auto-generated changeset for PR #1079 --- .changeset/pr-1079.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index 74413ac57..11c1e76c2 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -1,8 +1,6 @@ --- -'@sanity/cli-build': minor '@sanity/cli': minor -'create-sanity': minor --- feat: configure agent skills as part of `sanity init` \ No newline at end of file From fffa2156d9a3fef2104ca1df3a7a092d2cd0ade7 Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 14:16:48 -0700 Subject: [PATCH 10/15] chore: update changeset Signed-off-by: Jonah Snider --- .changeset/pr-1079.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index 11c1e76c2..83384e2a5 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -1,6 +1,5 @@ - --- '@sanity/cli': minor --- -feat: configure agent skills as part of `sanity init` \ No newline at end of file +feat: configure agent skills as part of `sanity init` From 1537796034d802dd9f255cf01a0da7d4a68df38a Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 14:22:44 -0700 Subject: [PATCH 11/15] chore: improve changeset description Signed-off-by: Jonah Snider --- .changeset/pr-1079.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index 83384e2a5..bc5cb396f 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -2,4 +2,4 @@ '@sanity/cli': minor --- -feat: configure agent skills as part of `sanity init` +Configure agent skills as part of `sanity init` From ab7d606c5ffabad92da90bd94746c57f52547e9c Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 14:34:05 -0700 Subject: [PATCH 12/15] chore: use @vercel/detect-agent instead of manually checking for Codex or Claude Signed-off-by: Jonah Snider --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ vitest.config.ts | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index e186ddba4..8215ef5f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@sanity/eslint-config-cli': specifier: workspace:* version: link:packages/@sanity/eslint-config-cli + '@vercel/detect-agent': + specifier: ^1.2.3 + version: 1.2.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -5665,6 +5668,10 @@ packages: peerDependencies: rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 + '@vercel/detect-agent@1.2.3': + resolution: {integrity: sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==} + engines: {node: '>=14'} + '@vercel/edge@1.2.2': resolution: {integrity: sha512-1+y+f6rk0Yc9ss9bRDgz/gdpLimwoRteKHhrcgHvEpjbP1nyT3ByqEMWm2BTcpIO5UtDmIFXc8zdq4LR190PDA==} @@ -15378,6 +15385,8 @@ snapshots: - babel-plugin-macros - supports-color + '@vercel/detect-agent@1.2.3': {} + '@vercel/edge@1.2.2': {} '@vercel/error-utils@2.0.3': {} 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 From 6b130e9ecd7bc868309c6d32e42e1392cfd0376f Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 15:18:17 -0700 Subject: [PATCH 13/15] refactor: create shared getSkillsCliAgent() helper for mapping editors to `skills` agents Signed-off-by: Jonah Snider --- packages/@sanity/cli/src/actions/mcp/editorConfigs.ts | 7 +++++++ packages/@sanity/cli/src/actions/mcp/setupMCP.ts | 4 ++-- packages/@sanity/cli/src/actions/skills/setupSkills.ts | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts index f56179348..f524103e3 100644 --- a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts +++ b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts @@ -394,3 +394,10 @@ 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 + } +} diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 55e22b4e2..c6038ef38 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -5,7 +5,7 @@ import {logSymbols} from '@sanity/cli-core/ux' import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js' import {setupSkills} from '../skills/setupSkills.js' import {detectAvailableEditors} from './detectAvailableEditors.js' -import {EDITOR_CONFIGS, type EditorName} from './editorConfigs.js' +import {EDITOR_CONFIGS, type EditorName, getSkillsCliAgent} from './editorConfigs.js' import {promptForMCPSetup} from './promptForMCPSetup.js' import {validateEditorTokens} from './validateEditorTokens.js' import {writeMCPConfig} from './writeMCPConfig.js' @@ -101,7 +101,7 @@ export async function setupMCP(options?: MCPSetupOptions): Promise !e.configured || e.authStatus !== 'valid' const needsSkillsSetup = (e: (typeof editors)[number]) => - Boolean(cwd && (EDITOR_CONFIGS[e.name] as {skillsCliAgent?: string}).skillsCliAgent) + Boolean(cwd && e.configured && e.authStatus === 'valid' && getSkillsCliAgent(e.name)) const actionable = editors.filter((e) => needsMCPSetup(e) || needsSkillsSetup(e)) if (actionable.length === 0) { diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index 2ac8cf8af..9a32caa99 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -6,7 +6,7 @@ import {logSymbols} from '@sanity/cli-core/ux' import {execa} from 'execa' import {getErrorMessage, toError} from '../../util/getErrorMessage.js' -import {EDITOR_CONFIGS} from '../mcp/editorConfigs.js' +import {getSkillsCliAgent} from '../mcp/editorConfigs.js' import {type Editor} from '../mcp/types.js' const skillsDebug = subdebug('skills:setup') @@ -64,7 +64,7 @@ export async function setupSkills(options: SetupSkillsOptions): Promise { - const agent = (EDITOR_CONFIGS[editor.name] as {skillsCliAgent?: string}).skillsCliAgent + const agent = getSkillsCliAgent(editor.name) return agent ? [agent] : [] }) From f2301c97de88d7cf64df2461a3b25048e8bc502c Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 15:20:11 -0700 Subject: [PATCH 14/15] test: avoid side effects from mkdir in test Signed-off-by: Jonah Snider --- .../cli/src/actions/skills/__tests__/setupSkills.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index 69434a995..8c0e03153 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -4,11 +4,18 @@ import {type Editor} from '../../mcp/types.js' import {SANITY_SKILLS_REPO, setupSkills} from '../setupSkills.js' const mockExeca = vi.hoisted(() => vi.fn()) +const mockMkdir = vi.hoisted(() => vi.fn()) vi.mock('execa', () => ({ execa: mockExeca, })) +vi.mock('node:fs/promises', () => ({ + default: { + mkdir: mockMkdir, + }, +})) + function editor(name: Editor['name']): Editor { return {configPath: `/tmp/${name}.json`, configured: false, name} } @@ -51,6 +58,7 @@ describe('setupSkills', () => { }) expect(mockExeca).toHaveBeenCalledTimes(1) + expect(mockMkdir).toHaveBeenCalledWith(PROJECT_DIR, {recursive: true}) expect(mockExeca).toHaveBeenCalledWith( 'npx', ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], From d3b61c207b6cf6d3807489ee032641b4cb6f872f Mon Sep 17 00:00:00 2001 From: Jonah Snider Date: Wed, 13 May 2026 15:20:58 -0700 Subject: [PATCH 15/15] refactor: remove unused `mode` param from setupSkills Signed-off-by: Jonah Snider --- .../skills/__tests__/setupSkills.test.ts | 11 -- .../cli/src/actions/skills/setupSkills.ts | 14 +- .../commands/mcp/__tests__/configure.test.ts | 126 +++++------------- packages/@sanity/cli/src/services/mcp.ts | 2 +- 4 files changed, 32 insertions(+), 121 deletions(-) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index 8c0e03153..1af030192 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -27,17 +27,6 @@ describe('setupSkills', () => { vi.clearAllMocks() }) - test('mode: skip returns early without calling npx', async () => { - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Cursor')], - mode: 'skip', - }) - - expect(result).toEqual({installedAgents: [], skipped: true}) - expect(mockExeca).not.toHaveBeenCalled() - }) - test('skips when no editors have a skills agent mapping', async () => { // Zed and MCPorter do not have a skillsCliAgent mapping const result = await setupSkills({ diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index 9a32caa99..16e0aed41 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -32,13 +32,6 @@ interface SetupSkillsOptions { * for each editor's mapped `skillsCliAgent`. */ editors: Editor[] - - /** - * Controls how skills setup behaves: - * - 'auto': Install for the editors' mapped agents (default) - * - 'skip': Skip skills installation entirely - */ - mode?: 'auto' | 'skip' } interface SetupSkillsResult { @@ -56,12 +49,7 @@ interface SetupSkillsResult { * best-effort and should never abort MCP setup or `sanity init`. */ export async function setupSkills(options: SetupSkillsOptions): Promise { - const {cwd, editors, mode = 'auto'} = options - - if (mode === 'skip') { - skillsDebug('Skipping skills installation (mode: skip)') - return {installedAgents: [], skipped: true} - } + const {cwd, editors} = options const agents = editors.flatMap((editor) => { const agent = getSkillsCliAgent(editor.name) 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 42526767f..aa78ef613 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 { @@ -70,6 +80,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 // --------------------------------------------------------------------------- @@ -146,22 +160,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, []) @@ -413,7 +415,7 @@ const mcporterTestCases: Array<{ // Main test suite // --------------------------------------------------------------------------- -describe('#mcp:configure', () => { +describe.sequential('#mcp:configure', () => { beforeEach(async () => { mockEnsureAuthenticated.mockResolvedValue({ email: 'test@example.com', @@ -422,11 +424,13 @@ describe('#mcp:configure', () => { provider: 'github', }) mockExistsSync.mockReturnValue(false) - mockReadFile.mockResolvedValue('{}') // Default: empty config file + mockReadFile.mockResolvedValue('{}') mockWriteFile.mockResolvedValue() mockExeca.mockRejectedValue(new Error('Not installed')) + mockCreateMCPToken.mockReset() mockSetupSkills.mockResolvedValue({installedAgents: [], skipped: true}) - createTestToken('test-token') + mockValidateMCPToken.mockReset() + createTestToken('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature') }) afterEach(() => { @@ -522,11 +526,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, []) @@ -579,12 +579,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']) @@ -627,11 +622,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']) @@ -672,18 +663,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, []) @@ -701,15 +681,6 @@ describe('#mcp:configure', () => { ) expect(stdout).toContain('MCP configured for Cursor, VS Code') - - // Skills install was invoked for both selected editors - expect(mockSetupSkills).toHaveBeenCalledTimes(1) - expect(mockSetupSkills).toHaveBeenCalledWith({ - editors: expect.arrayContaining([ - expect.objectContaining({name: 'Cursor'}), - expect.objectContaining({name: 'VS Code'}), - ]), - }) }) test('auto-selects all editors in non-interactive mode without prompting', async () => { @@ -720,18 +691,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, []) @@ -756,11 +716,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['Claude Code']) - 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, []) @@ -777,18 +733,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['Claude Code']) - 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')) @@ -847,18 +792,7 @@ describe('#mcp:configure', () => { mockCheckbox.mockResolvedValue(['Claude Code']) - 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/services/mcp.ts b/packages/@sanity/cli/src/services/mcp.ts index 9517a52f2..97488e60a 100644 --- a/packages/@sanity/cli/src/services/mcp.ts +++ b/packages/@sanity/cli/src/services/mcp.ts @@ -1,7 +1,7 @@ import {getGlobalCliClient, subdebug} from '@sanity/cli-core' import {isHttpError} from '@sanity/client' -export const MCP_API_VERSION = '2025-12-09' +const MCP_API_VERSION = '2025-12-09' export const MCP_SERVER_URL = 'https://mcp.sanity.io' export const MCP_JOURNEY_API_VERSION = 'v2024-02-23'