Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-1079.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sanity/cli': minor
---

Configure agent skills as part of `sanity init`
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@commitlint/types": "^20.4.0",
"@eslint/compat": "catalog:",
"@sanity/eslint-config-cli": "workspace:*",
"@vercel/detect-agent": "^1.2.3",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I can only see this being used in the vitest config, do we need to add a new dep for this? (It's only dev dep so nit)

"@vitest/coverage-istanbul": "catalog:",
"eslint": "catalog:",
"husky": "^9.1.7",
Expand Down
3 changes: 2 additions & 1 deletion packages/@sanity/cli/src/actions/init/initAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,12 @@ 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,
detectedEditors: mcpResult.detectedEditors,
installedSkillsCliAgents: mcpResult.installedSkillsCliAgents,
skipped: mcpResult.skipped,
step: 'mcpSetup',
})
Expand Down
131 changes: 130 additions & 1 deletion packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Expand All @@ -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'})

Expand Down Expand Up @@ -100,4 +110,123 @@ describe('setupMCP', () => {

expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors)
})

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'},
]
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({cwd: '/tmp/project', mode: 'auto'})

expect(mockSetupSkills).toHaveBeenCalledTimes(1)
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)
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({cwd: '/tmp/project', 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 detected', async () => {
mockDetectAvailableEditors.mockResolvedValue([])

await setupMCP({cwd: '/tmp/project', mode: 'auto'})

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'])
})
})
46 changes: 41 additions & 5 deletions packages/@sanity/cli/src/actions/mcp/editorConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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: <VS Code User>/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
Expand All @@ -318,23 +330,26 @@ 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
Cursor: {
...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
Expand All @@ -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: <VS Code User dir>/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: <VS Code Insiders User dir>/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,
Expand All @@ -365,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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function promptForMCPSetup(editors: Editor[]): Promise<Editor[] | n

const selectedNames = await checkbox({
choices: editorChoices,
message: 'Configure Sanity MCP server?',
message: 'Configure Sanity MCP and agent skills for your editor?',
Comment thread
cursor[bot] marked this conversation as resolved.
})

// User can deselect all to skip
Expand Down
Loading
Loading