From 7381af49786f4c74fcb07c0fc06114eb12885f10 Mon Sep 17 00:00:00 2001 From: James Woods Date: Thu, 21 May 2026 09:35:51 +0100 Subject: [PATCH 1/9] feat: configure agent skills as part of `sanity init` Co-authored-by: Jonah Snider --- .changeset/pr-1079.md | 5 + package.json | 1 + .../cli/src/actions/init/initAction.ts | 24 ++- .../actions/mcp/__tests__/setupMCP.test.ts | 26 +++ .../cli/src/actions/mcp/editorConfigs.ts | 46 ++++- .../@sanity/cli/src/actions/mcp/setupMCP.ts | 11 +- .../skills/__tests__/setupSkills.test.ts | 171 ++++++++++++++++++ .../actions/skills/promptForSkillsSetup.ts | 15 ++ .../cli/src/actions/skills/setupSkills.ts | 138 ++++++++++++++ .../init/init.authentication.test.ts | 12 ++ .../__tests__/init/init.bootstrap-app.test.ts | 12 ++ .../init/init.create-new-project.test.ts | 12 ++ .../init/init.get-project-details.test.ts | 12 ++ .../__tests__/init/init.nextjs.test.ts | 12 ++ .../commands/__tests__/init/init.plan.test.ts | 12 ++ .../__tests__/init/init.staging-env.test.ts | 14 +- .../commands/mcp/__tests__/configure.test.ts | 117 +++--------- packages/@sanity/cli/src/services/mcp.ts | 2 +- .../cli/src/telemetry/init.telemetry.ts | 10 + pnpm-lock.yaml | 9 + vitest.config.ts | 3 +- 21 files changed, 567 insertions(+), 97 deletions(-) create mode 100644 .changeset/pr-1079.md create mode 100644 packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts create mode 100644 packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts create mode 100644 packages/@sanity/cli/src/actions/skills/setupSkills.ts diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md new file mode 100644 index 000000000..bc5cb396f --- /dev/null +++ b/.changeset/pr-1079.md @@ -0,0 +1,5 @@ +--- +'@sanity/cli': minor +--- + +Configure agent skills as part of `sanity init` 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/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index a1fd41b74..f4d6d8a67 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,11 @@ 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' ? [] : await detectAvailableEditors() + + const mcpResult = await setupMCP({editors: detectedEditors, mode: options.mcpMode}) trace.log({ configuredEditors: mcpResult.configuredEditors, @@ -200,6 +206,22 @@ export async function initAction(options: InitOptions, context: InitContext): Pr } const mcpConfigured = mcpResult.configuredEditors + const skillsResult = await setupSkills({ + cwd: outputPath, + editors: detectedEditors, + mode: options.mcpMode, + }) + + trace.log({ + installedAgents: skillsResult.installedAgents, + installedForEditors: skillsResult.installedForEditors, + skipped: skillsResult.skipped, + step: 'skillsSetup', + }) + if (skillsResult.error) { + trace.error(skillsResult.error) + } + const {alreadyConfiguredEditors} = mcpResult if (alreadyConfiguredEditors.length > 0) { const label = 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..eac7c603f 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -1,6 +1,7 @@ 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()) @@ -100,4 +101,29 @@ describe('setupMCP', () => { expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) }) + + test('uses caller-provided editors without re-detecting', async () => { + const editors: Editor[] = [{configPath: '/tmp/cursor', configured: false, name: 'Cursor'}] + mockValidateEditorTokens.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue(editors) + mockCreateMCPToken.mockResolvedValue('test-token') + mockWriteMCPConfig.mockResolvedValue(undefined) + + 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 () => { + const editors = [{authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}] + mockDetectAvailableEditors.mockResolvedValue(editors) + mockValidateEditorTokens.mockResolvedValue(undefined) + + const result = await setupMCP({mode: 'auto'}) + + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts index 8038ff82f..f524103e3 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, @@ -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 + } +} diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 28bf6372a..569b9d006 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -6,6 +6,7 @@ import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js' import {detectAvailableEditors} from './detectAvailableEditors.js' import {EDITOR_CONFIGS, type EditorName} from './editorConfigs.js' import {promptForMCPSetup} from './promptForMCPSetup.js' +import {type Editor} from './types.js' import {validateEditorTokens} from './validateEditorTokens.js' import {writeMCPConfig} from './writeMCPConfig.js' @@ -14,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 { + /** + * 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. @@ -59,7 +68,7 @@ export async function setupMCP(options?: MCPSetupOptions): Promise e.name) mcpDebug('Detected %d editors: %s', detectedEditors.length, detectedEditors) 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..cb6f173df --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -0,0 +1,171 @@ +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()) +const mockMkdir = vi.hoisted(() => vi.fn()) +const mockDetectAvailableEditors = vi.hoisted(() => vi.fn()) +const mockPromptForSkillsSetup = vi.hoisted(() => vi.fn()) + +vi.mock('execa', () => ({ + execa: mockExeca, +})) + +vi.mock('node:fs/promises', () => ({ + default: { + mkdir: mockMkdir, + }, +})) + +vi.mock('../../mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: mockDetectAvailableEditors, +})) + +vi.mock('../promptForSkillsSetup.js', () => ({ + promptForSkillsSetup: mockPromptForSkillsSetup, +})) + +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 detecting or prompting', async () => { + const result = await setupSkills({cwd: PROJECT_DIR, mode: 'skip'}) + + expect(result).toEqual({installedAgents: [], installedForEditors: [], skipped: true}) + expect(mockDetectAvailableEditors).not.toHaveBeenCalled() + expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() + expect(mockExeca).not.toHaveBeenCalled() + }) + + test('skips when no detected editors have a skills agent mapping', async () => { + // Zed and MCPorter do not have a skillsCliAgent mapping + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Zed'), editor('MCPorter')], + mode: 'auto', + }) + + expect(result).toEqual({installedAgents: [], installedForEditors: [], skipped: true}) + expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() + expect(mockExeca).not.toHaveBeenCalled() + }) + + test('mode: auto installs for all eligible editors without prompting', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Cursor'), editor('Claude Code')], + mode: 'auto', + }) + + expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() + expect(mockMkdir).toHaveBeenCalledWith(PROJECT_DIR, {recursive: true}) + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], + expect.objectContaining({cwd: PROJECT_DIR, stdio: 'pipe'}), + ) + expect(result.installedAgents).toEqual(['cursor', 'claude-code']) + expect(result.installedForEditors).toEqual(['Cursor', 'Claude Code']) + expect(result.skipped).toBe(false) + expect(result.error).toBeUndefined() + }) + + test('mode: prompt asks the user with a single confirm', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + mockPromptForSkillsSetup.mockResolvedValue(true) + + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Cursor'), editor('Zed'), editor('Claude Code')], + mode: 'prompt', + }) + + expect(mockPromptForSkillsSetup).toHaveBeenCalledTimes(1) + expect(result.installedAgents).toEqual(['cursor', 'claude-code']) + expect(result.skipped).toBe(false) + }) + + test('mode: prompt returns skipped when the user declines', async () => { + mockPromptForSkillsSetup.mockResolvedValue(false) + + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Cursor')], + mode: 'prompt', + }) + + expect(mockExeca).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.installedAgents).toEqual([]) + }) + + test('deduplicates agents (Cline and Cline CLI map to the same agent)', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Cline'), editor('Cline CLI')], + mode: 'auto', + }) + + expect(result.installedAgents).toEqual(['cline']) + expect(mockExeca).toHaveBeenCalledWith( + 'npx', + ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cline', '-y'], + expect.any(Object), + ) + }) + + test('detects editors when not provided by caller', async () => { + mockExeca.mockResolvedValue({exitCode: 0}) + mockDetectAvailableEditors.mockResolvedValue([editor('Cursor')]) + + await setupSkills({cwd: PROJECT_DIR, mode: 'auto'}) + + expect(mockDetectAvailableEditors).toHaveBeenCalled() + expect(mockExeca).toHaveBeenCalled() + }) + + 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({ + cwd: PROJECT_DIR, + editors: [editor('Cursor')], + mode: 'auto', + }) + + 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({ + cwd: PROJECT_DIR, + editors: [editor('VS Code'), editor('VS Code Insiders')], + mode: 'auto', + }) + + 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/promptForSkillsSetup.ts b/packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts new file mode 100644 index 000000000..67c2957e7 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts @@ -0,0 +1,15 @@ +import {confirm} from '@sanity/cli-core/ux' + +/** + * Prompt the user with a single yes/no for installing Sanity agent skills. + * + * Skills are project-local files, so we don't ask per-editor — if the user + * says yes, skills are installed for every detected editor that has a + * skills CLI mapping. + */ +export async function promptForSkillsSetup(): Promise { + return await confirm({ + default: true, + message: 'Install Sanity agent skills into this project?', + }) +} 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..2278c8e02 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs/promises' + +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 {detectAvailableEditors} from '../mcp/detectAvailableEditors.js' +import {getSkillsCliAgent} from '../mcp/editorConfigs.js' +import {type Editor} from '../mcp/types.js' +import {promptForSkillsSetup} from './promptForSkillsSetup.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 { + /** + * 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 + + /** + * Pre-detected editors. When omitted, `detectAvailableEditors()` is called. + * Passing this through from the caller avoids re-running detection that + * `setupMCP` has already done during `sanity init`. + */ + editors?: Editor[] + + /** + * Controls how skills setup behaves: + * - 'prompt': Ask the user with a single yes/no (default) + * - 'auto': Install for all eligible editors without prompting + * - 'skip': Skip skills installation entirely + */ + mode?: 'auto' | 'prompt' | 'skip' +} + +interface SetupSkillsResult { + /** `--agent` values that were targeted by `npx skills add` */ + installedAgents: string[] + /** Editor display names that received skills */ + installedForEditors: string[] + skipped: boolean + + error?: Error +} + +/** + * Set up Sanity agent skills for the project. + * + * Asks the user once (yes/no) whether to install skills, then runs + * `npx skills add` for every detected editor that has a mapped agent. + * Failures are surfaced as warnings and do not throw — skills install is + * best-effort and should never abort `sanity init`. + */ +export async function setupSkills(options: SetupSkillsOptions): Promise { + const {cwd, mode = 'prompt'} = options + const empty: SetupSkillsResult = {installedAgents: [], installedForEditors: [], skipped: true} + + if (mode === 'skip') { + skillsDebug('Skipping skills setup (mode: skip)') + return empty + } + + const editors = options.editors ?? (await detectAvailableEditors()) + + const eligible = editors.flatMap((editor) => { + const agent = getSkillsCliAgent(editor.name) + return agent ? [{agent, editor}] : [] + }) + + if (eligible.length === 0) { + skillsDebug('No detected editors have a skills agent mapping — skipping') + return empty + } + + const uniqueAgents = [...new Set(eligible.map((e) => e.agent))] + const editorLabels = [...new Set(eligible.map((e) => e.editor.name))] + + if (mode === 'prompt') { + const confirmed = await promptForSkillsSetup() + if (!confirmed) { + ux.stdout('Agent skills installation skipped') + return empty + } + } + + const args = [ + '-y', + 'skills', + 'add', + SANITY_SKILLS_REPO, + ...uniqueAgents.flatMap((agent) => ['-a', agent]), + '-y', + ] + + skillsDebug('Running: npx %s (cwd: %s)', args.join(' '), cwd) + + try { + // The cwd may not exist yet when called during `sanity init` (project + // bootstrap happens later). Create it so `npx` doesn't bail with ENOENT. + await fs.mkdir(cwd, {recursive: true}) + const result = await execa('npx', args, {cwd, 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 ${editorLabels.join(', ')}`) + return { + installedAgents: uniqueAgents, + installedForEditors: editorLabels, + 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: [], + installedForEditors: [], + 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..0fd82db16 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 @@ -80,6 +80,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + 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..f0586eb21 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 @@ -95,6 +95,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + skipped: true, + }), +})) + vi.mock('../../../util/packageManager/installPackages.js', () => ({ installDeclaredPackages: mocks.installDeclaredPackages.mockResolvedValue(undefined), })) 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..1d761eede 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 @@ -113,6 +113,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) 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..4de6d6913 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 @@ -93,6 +93,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + 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..846c857fe 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 @@ -116,6 +116,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + 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..f169c3d75 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 @@ -92,6 +92,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + skipped: true, + }), +})) + vi.mock('../../../actions/init/checkNextJsReactCompatibility.js', () => ({ checkNextJsReactCompatibility: vi.fn().mockResolvedValue(undefined), })) 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..683068f02 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 @@ -95,6 +95,18 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ }), })) +vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ + detectAvailableEditors: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: vi.fn().mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + skipped: true, + }), +})) + vi.mock('../../../util/packageManager/installPackages.js', () => ({ installDeclaredPackages: mocks.installDeclaredPackages.mockResolvedValue(undefined), })) @@ -298,6 +310,6 @@ describe('#init: staging env propagation', () => { }, ) - expect(setupMCP).toHaveBeenCalledWith({mode: 'skip'}) + expect(setupMCP).toHaveBeenCalledWith({editors: [], mode: 'skip'}) }) }) 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..b171b19b6 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, []) @@ -407,7 +409,7 @@ const mcporterTestCases: Array<{ // Main test suite // --------------------------------------------------------------------------- -describe('#mcp:configure', () => { +describe.sequential('#mcp:configure', () => { beforeEach(async () => { mockEnsureAuthenticated.mockResolvedValue({ email: 'test@example.com', @@ -416,10 +418,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(() => { @@ -515,11 +519,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, []) @@ -572,12 +572,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']) @@ -620,11 +615,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']) @@ -665,18 +656,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, []) @@ -704,18 +684,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, []) @@ -740,11 +709,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, []) @@ -761,18 +726,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')) @@ -831,18 +785,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' diff --git a/packages/@sanity/cli/src/telemetry/init.telemetry.ts b/packages/@sanity/cli/src/telemetry/init.telemetry.ts index 984505ea4..204513390 100644 --- a/packages/@sanity/cli/src/telemetry/init.telemetry.ts +++ b/packages/@sanity/cli/src/telemetry/init.telemetry.ts @@ -87,6 +87,15 @@ interface MCPSetupStep { step: 'mcpSetup' } +interface SkillsSetupStep { + /** `--agent` values that received the Sanity skills bundle */ + installedAgents: string[] + /** Editor display names that received skills (e.g. "Cursor", "Claude Code") */ + installedForEditors: string[] + skipped: boolean + step: 'skillsSetup' +} + interface ConfigureAppProjectStep { selectedOption: 'create' | 'existing' | 'skip' step: 'configureAppProject' @@ -103,6 +112,7 @@ export type InitStepResult = | SelectPackageManagerStep | SelectTemplateStep | SendCommunityInviteStep + | SkillsSetupStep | StartStep | UseDefaultPlanCoupon | UseDefaultPlanId diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb7a5abd0..6f39abe0b 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) @@ -5725,6 +5728,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==} @@ -15585,6 +15592,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 c2fbcd98db1d0a5387a19a5e1ad1d9984d86d425 Mon Sep 17 00:00:00 2001 From: James Woods Date: Thu, 21 May 2026 09:56:38 +0100 Subject: [PATCH 2/9] feat(init): auto-install skills after scaffolding with --no-skills opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills install is now part of the default `sanity init` flow: no yes/no prompt, times it post-scaffold so the install lands in the new project dir, and runs project-wide for every detected editor with a `skillsCliAgent` mapping. - New `--no-skills` flag mirroring `--no-mcp`, with matching `skillsMode` plumbing alongside `mcpMode`. Both share env / non-interactive gating so e2e / UI tests don't shell out to `npx skills add`. - Editor detection is shared between MCP and skills and only runs when at least one of the two is active. - `installSkills()` in initAction wraps the call in a defensive try/catch so init never fails on a scaffolded project even if `setupSkills` violates its no-throw contract. - Dropped the `fs.mkdir` workaround from `setupSkills` — the scaffolded project directory always exists by the time we run. - `'prompt'` mode and `promptForSkillsSetup` helper are kept in `setupSkills` for a future `sanity skills add` command. --- .../init/__tests__/flagsToInitOptions.test.ts | 22 ++++-- .../actions/init/__tests__/initAction.test.ts | 1 + .../cli/src/actions/init/initAction.ts | 45 +++++++---- .../@sanity/cli/src/actions/init/types.ts | 4 + .../skills/__tests__/setupSkills.test.ts | 8 -- .../cli/src/actions/skills/setupSkills.ts | 42 +++-------- .../__tests__/init/init.bootstrap-app.test.ts | 11 ++- .../__tests__/init/init.command.test.ts | 74 +++++++++++++++++++ .../__tests__/init/init.setup.test.ts | 1 + packages/@sanity/cli/src/commands/init.ts | 25 +++++-- 10 files changed, 167 insertions(+), 66 deletions(-) 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 f4d6d8a67..75d34bff4 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -191,7 +191,10 @@ export async function initAction(options: InitOptions, context: InitContext): Pr // 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' ? [] : await detectAvailableEditors() + const detectedEditors = + options.mcpMode === 'skip' && options.skillsMode === 'skip' + ? [] + : await detectAvailableEditors() const mcpResult = await setupMCP({editors: detectedEditors, mode: options.mcpMode}) @@ -206,20 +209,29 @@ export async function initAction(options: InitOptions, context: InitContext): Pr } const mcpConfigured = mcpResult.configuredEditors - const skillsResult = await setupSkills({ - cwd: outputPath, - editors: detectedEditors, - mode: options.mcpMode, - }) - - trace.log({ - installedAgents: skillsResult.installedAgents, - installedForEditors: skillsResult.installedForEditors, - skipped: skillsResult.skipped, - step: 'skillsSetup', - }) - if (skillsResult.error) { - trace.error(skillsResult.error) + async function installSkills(): Promise { + if (options.skillsMode === 'skip') return + try { + const skillsResult = await setupSkills({ + cwd: outputPath, + editors: detectedEditors, + mode: 'auto', + }) + trace.log({ + installedAgents: skillsResult.installedAgents, + installedForEditors: skillsResult.installedForEditors, + 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 @@ -251,6 +263,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr trace, workDir, }) + await installSkills() trace.complete() return } @@ -295,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/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index cb6f173df..fd8af2feb 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -4,7 +4,6 @@ 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()) const mockDetectAvailableEditors = vi.hoisted(() => vi.fn()) const mockPromptForSkillsSetup = vi.hoisted(() => vi.fn()) @@ -12,12 +11,6 @@ vi.mock('execa', () => ({ execa: mockExeca, })) -vi.mock('node:fs/promises', () => ({ - default: { - mkdir: mockMkdir, - }, -})) - vi.mock('../../mcp/detectAvailableEditors.js', () => ({ detectAvailableEditors: mockDetectAvailableEditors, })) @@ -69,7 +62,6 @@ describe('setupSkills', () => { }) expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() - expect(mockMkdir).toHaveBeenCalledWith(PROJECT_DIR, {recursive: true}) expect(mockExeca).toHaveBeenCalledWith( 'npx', ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index 2278c8e02..e5e0629f0 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs/promises' - import {ux} from '@oclif/core' import {subdebug} from '@sanity/cli-core' import {logSymbols} from '@sanity/cli-core/ux' @@ -13,42 +11,28 @@ import {promptForSkillsSetup} from './promptForSkillsSetup.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). - */ +/** Source repo for `npx skills add`. See https://www.sanity.io/docs/ai/skills. */ 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`). - */ + /** Working directory for `npx skills add`. Must already exist. */ cwd: string - /** - * Pre-detected editors. When omitted, `detectAvailableEditors()` is called. - * Passing this through from the caller avoids re-running detection that - * `setupMCP` has already done during `sanity init`. - */ + /** Pre-detected editors. When omitted, `detectAvailableEditors()` is called. */ editors?: Editor[] /** - * Controls how skills setup behaves: - * - 'prompt': Ask the user with a single yes/no (default) - * - 'auto': Install for all eligible editors without prompting - * - 'skip': Skip skills installation entirely + * - `'auto'`: install for all eligible editors without prompting + * - `'prompt'`: ask the user with a single yes/no (reserved for a future + * `sanity skills add` command — `sanity init` never uses this) + * - `'skip'`: skip skills installation entirely */ mode?: 'auto' | 'prompt' | 'skip' } interface SetupSkillsResult { - /** `--agent` values that were targeted by `npx skills add` */ + /** `--agent` values passed to `npx skills add` */ installedAgents: string[] - /** Editor display names that received skills */ installedForEditors: string[] skipped: boolean @@ -56,12 +40,9 @@ interface SetupSkillsResult { } /** - * Set up Sanity agent skills for the project. - * - * Asks the user once (yes/no) whether to install skills, then runs - * `npx skills add` for every detected editor that has a mapped agent. + * Runs `npx skills add` for every detected editor with a mapped skills agent. * Failures are surfaced as warnings and do not throw — skills install is - * best-effort and should never abort `sanity init`. + * best-effort and must never abort `sanity init`. */ export async function setupSkills(options: SetupSkillsOptions): Promise { const {cwd, mode = 'prompt'} = options @@ -107,9 +88,6 @@ export async function setupSkills(options: SetupSkillsOptions): Promise ({ installDeclaredPackages: vi.fn(), select: vi.fn(), setupMCP: vi.fn(), + setupSkills: vi.fn(), })) vi.mock('@sanity/cli-core', async (importOriginal) => { @@ -100,7 +101,7 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ })) vi.mock('../../../actions/skills/setupSkills.js', () => ({ - setupSkills: vi.fn().mockResolvedValue({ + setupSkills: mocks.setupSkills.mockResolvedValue({ installedAgents: [], installedForEditors: [], skipped: true, @@ -219,6 +220,14 @@ 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 in 'auto' mode after scaffolding has completed. + expect(mocks.setupSkills).toHaveBeenCalledWith( + expect.objectContaining({cwd: convertToSystemPath('/test/output'), mode: 'auto'}), + ) + 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.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/init.ts b/packages/@sanity/cli/src/commands/init.ts index ca539ef9f..ec02788f9 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 into the project', + }), 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 shell out to `npx skills add`. + 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}) From 42f1857228119ca4d9984db4df65cd1bb3151f94 Mon Sep 17 00:00:00 2001 From: James Woods Date: Thu, 21 May 2026 10:47:47 +0100 Subject: [PATCH 3/9] refactor(skills): bundle skills CLI instead of shelling out to npx Adds `skills` to @sanity/cli dependencies and resolves the bundled bin via `import.meta.resolve` so init runs the version we ship rather than paying the `npx -y` registry lookup at runtime. Also passes `--project` to `skills add` explicitly instead of relying on the CLI's cwd-based scope auto-detect. --- packages/@sanity/cli/package.json | 1 + .../skills/__tests__/setupSkills.test.ts | 34 +++++++++++++------ .../cli/src/actions/skills/setupSkills.ts | 32 +++++++++++------ pnpm-lock.yaml | 12 +++++++ 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 3c9777311..53c3aa301 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -126,6 +126,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/skills/__tests__/setupSkills.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts index fd8af2feb..cb8a56a71 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -1,7 +1,7 @@ import {afterEach, describe, expect, test, vi} from 'vitest' import {type Editor} from '../../mcp/types.js' -import {SANITY_SKILLS_REPO, setupSkills} from '../setupSkills.js' +import {SANITY_SKILLS_REPO, setupSkills, SKILLS_BIN_PATH} from '../setupSkills.js' const mockExeca = vi.hoisted(() => vi.fn()) const mockDetectAvailableEditors = vi.hoisted(() => vi.fn()) @@ -63,8 +63,18 @@ describe('setupSkills', () => { expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() expect(mockExeca).toHaveBeenCalledWith( - 'npx', - ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cursor', '-a', 'claude-code', '-y'], + process.execPath, + [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--project', + '-a', + 'cursor', + '-a', + 'claude-code', + '-y', + ], expect.objectContaining({cwd: PROJECT_DIR, stdio: 'pipe'}), ) expect(result.installedAgents).toEqual(['cursor', 'claude-code']) @@ -113,8 +123,8 @@ describe('setupSkills', () => { expect(result.installedAgents).toEqual(['cline']) expect(mockExeca).toHaveBeenCalledWith( - 'npx', - ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'cline', '-y'], + process.execPath, + [SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, '--project', '-a', 'cline', '-y'], expect.any(Object), ) }) @@ -129,8 +139,8 @@ describe('setupSkills', () => { expect(mockExeca).toHaveBeenCalled() }) - test('returns an error result when npx fails (does not throw)', async () => { - const installErr = new Error('npx exited 1') + 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({ @@ -142,7 +152,7 @@ describe('setupSkills', () => { expect(result.skipped).toBe(false) expect(result.installedAgents).toEqual([]) expect(result.error).toBeInstanceOf(Error) - expect(result.error?.message).toBe('npx exited 1') + expect(result.error?.message).toBe('skills exited 1') }) test('VS Code maps to github-copilot agent', async () => { @@ -155,9 +165,13 @@ describe('setupSkills', () => { }) expect(mockExeca).toHaveBeenCalledWith( - 'npx', - ['-y', 'skills', 'add', SANITY_SKILLS_REPO, '-a', 'github-copilot', '-y'], + process.execPath, + [SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, '--project', '-a', 'github-copilot', '-y'], expect.any(Object), ) }) + + test('resolves SKILLS_BIN_PATH to a path that points at the bundled cli', () => { + expect(SKILLS_BIN_PATH).toMatch(/skills\/bin\/cli\.mjs$/) + }) }) diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index e5e0629f0..ef53389f7 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -1,3 +1,5 @@ +import {fileURLToPath} from 'node:url' + import {ux} from '@oclif/core' import {subdebug} from '@sanity/cli-core' import {logSymbols} from '@sanity/cli-core/ux' @@ -11,11 +13,20 @@ import {promptForSkillsSetup} from './promptForSkillsSetup.js' const skillsDebug = subdebug('skills:setup') -/** Source repo for `npx skills add`. See https://www.sanity.io/docs/ai/skills. */ +/** Source repo for the bundled `skills` CLI. See https://www.sanity.io/docs/ai/skills. */ export const SANITY_SKILLS_REPO = 'sanity-io/agent-toolkit' +/** + * 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 { - /** Working directory for `npx skills add`. Must already exist. */ + /** Working directory for the `skills add` invocation. Must already exist. */ cwd: string /** Pre-detected editors. When omitted, `detectAvailableEditors()` is called. */ @@ -31,7 +42,7 @@ interface SetupSkillsOptions { } interface SetupSkillsResult { - /** `--agent` values passed to `npx skills add` */ + /** `--agent` values passed to `skills add` */ installedAgents: string[] installedForEditors: string[] skipped: boolean @@ -40,9 +51,10 @@ interface SetupSkillsResult { } /** - * Runs `npx skills add` for every detected editor with a mapped skills agent. - * Failures are surfaced as warnings and do not throw — skills install is - * best-effort and must never abort `sanity init`. + * Runs the bundled `skills add` for every detected editor with a mapped + * skills agent, scoped to the project (`--project`). Failures are surfaced + * as warnings and do not throw — skills install is best-effort and must + * never abort `sanity init`. */ export async function setupSkills(options: SetupSkillsOptions): Promise { const {cwd, mode = 'prompt'} = options @@ -77,18 +89,18 @@ export async function setupSkills(options: SetupSkillsOptions): Promise ['-a', agent]), '-y', ] - skillsDebug('Running: npx %s (cwd: %s)', args.join(' '), cwd) + skillsDebug('Running: %s %s (cwd: %s)', process.execPath, args.join(' '), cwd) try { - const result = await execa('npx', args, {cwd, stdio: 'pipe', timeout: 90_000}) + const result = await execa(process.execPath, args, {cwd, 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 ${editorLabels.join(', ')}`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f39abe0b..697f206c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -649,6 +649,9 @@ importers: semver: specifier: ^7.7.4 version: 7.7.4 + skills: + specifier: ^1.5.7 + version: 1.5.7 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -9103,6 +9106,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'} @@ -19507,6 +19515,10 @@ snapshots: sisteransi@1.0.5: {} + skills@1.5.7: + dependencies: + yaml: 2.8.4 + slash@3.0.0: {} slash@5.1.0: {} From 8b934a550694245d712134260e151b928c77bee1 Mon Sep 17 00:00:00 2001 From: James Woods Date: Thu, 21 May 2026 14:56:44 +0100 Subject: [PATCH 4/9] feat(skills): add `sanity skills add` and `sanity skills update` - `sanity skills add` installs Sanity agent skills into the current project for detected AI editors. Same code path as the post-init install: interactive prompt when TTY, auto when not. New `explicit` flag on `setupSkills` surfaces a hint when no eligible editors are detected so the command isn't silent. - `sanity skills update` refreshes any installed project skill whose `skills-lock.json` source points at a Sanity-owned GitHub org (currently `sanity-io` and `sanity-labs`), leaving foreign skills untouched. Runs the upstream `skills update` codepath rather than re-copying via `add`, so it's hash-checked and fast. Plumbed through oclif as a new `skills` topic; `check-topic-aliases.ts` updated to allowlist it. --- packages/@sanity/cli/oclif.config.js | 1 + .../cli/scripts/check-topic-aliases.ts | 1 + .../skills/__tests__/runSkillsUpdate.test.ts | 150 ++++++++++++++++++ .../skills/__tests__/setupSkills.test.ts | 15 ++ .../cli/src/actions/skills/runSkillsUpdate.ts | 144 +++++++++++++++++ .../cli/src/actions/skills/setupSkills.ts | 18 ++- .../src/commands/skills/__tests__/add.test.ts | 95 +++++++++++ .../commands/skills/__tests__/update.test.ts | 55 +++++++ .../@sanity/cli/src/commands/skills/add.ts | 47 ++++++ .../@sanity/cli/src/commands/skills/update.ts | 39 +++++ .../cli/src/telemetry/skills.telemetry.ts | 24 +++ 11 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts create mode 100644 packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts create mode 100644 packages/@sanity/cli/src/commands/skills/__tests__/add.test.ts create mode 100644 packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts create mode 100644 packages/@sanity/cli/src/commands/skills/add.ts create mode 100644 packages/@sanity/cli/src/commands/skills/update.ts create mode 100644 packages/@sanity/cli/src/telemetry/skills.telemetry.ts diff --git a/packages/@sanity/cli/oclif.config.js b/packages/@sanity/cli/oclif.config.js index 7c2245123..98fb6357e 100644 --- a/packages/@sanity/cli/oclif.config.js +++ b/packages/@sanity/cli/oclif.config.js @@ -27,6 +27,7 @@ export default { openapi: {description: 'Manage OpenAPI specifications'}, projects: {description: 'Manage Sanity projects'}, schemas: {description: 'Manage and validate schemas'}, + skills: {description: 'Install and update Sanity agent skills'}, telemetry: {description: 'Manage telemetry consent'}, tokens: {description: 'Manage API tokens for your project'}, users: {description: 'Manage project users and invitations'}, diff --git a/packages/@sanity/cli/scripts/check-topic-aliases.ts b/packages/@sanity/cli/scripts/check-topic-aliases.ts index e9f4b9bfe..0103cdd78 100644 --- a/packages/@sanity/cli/scripts/check-topic-aliases.ts +++ b/packages/@sanity/cli/scripts/check-topic-aliases.ts @@ -35,6 +35,7 @@ const knownTopicsWithoutAliases: Set = new Set([ 'mcp', 'media', 'openapi', + 'skills', 'telemetry', ]) diff --git a/packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts new file mode 100644 index 000000000..5cb2562cc --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs/promises' + +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {isSanityOwnedSource, runSkillsUpdate} from '../runSkillsUpdate.js' +import {SKILLS_BIN_PATH} from '../setupSkills.js' + +const mockExeca = vi.hoisted(() => vi.fn()) +const mockReadFile = vi.hoisted(() => vi.fn()) + +vi.mock('execa', () => ({ + execa: mockExeca, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + ...actual, + readFile: mockReadFile, + }, + } +}) + +const CWD = '/tmp/project' + +function lockfile(skills: Record): string { + return JSON.stringify({ + skills: Object.fromEntries( + Object.entries(skills).map(([name, info]) => [ + name, + {source: info.source, sourceType: info.sourceType ?? 'github'}, + ]), + ), + version: 1, + }) +} + +describe('isSanityOwnedSource', () => { + test.each([ + ['sanity-io/agent-toolkit', true], + ['sanity-io/next-sanity', true], + ['sanity-labs/internal-skills', true], + ['git@github.com:sanity-labs/internal-skills.git', true], + ['git@github.com:sanity-io/agent-toolkit.git', true], + ['SANITY-IO/agent-toolkit', true], + [' sanity-io/agent-toolkit ', true], + + ['vercel-labs/skills', false], + ['mattpocock/skills', false], + ['avdlee/swiftui-agent-skill', false], + ['not-sanity-io/foo', false], + ['sanity-io-fake/foo', false], + ['', false], + [undefined, false], + ])('isSanityOwnedSource(%j) === %j', (input, expected) => { + expect(isSanityOwnedSource(input)).toBe(expected) + }) +}) + +describe('runSkillsUpdate', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('invokes `skills update --project -y` with every Sanity-owned skill from the lockfile', async () => { + mockReadFile.mockResolvedValue( + lockfile({ + 'foreign-skill': {source: 'mattpocock/skills'}, + 'internal-skill': { + source: 'git@github.com:sanity-labs/internal-skills.git', + sourceType: 'git', + }, + 'sanity-best-practices': {source: 'sanity-io/agent-toolkit'}, + 'seo-aeo-best-practices': {source: 'sanity-io/agent-toolkit'}, + }), + ) + mockExeca.mockResolvedValue({exitCode: 0, stdout: 'updated 3 skills'}) + + const result = await runSkillsUpdate({cwd: CWD}) + + expect(mockExeca).toHaveBeenCalledWith( + process.execPath, + [ + SKILLS_BIN_PATH, + 'update', + '--project', + '-y', + 'internal-skill', + 'sanity-best-practices', + 'seo-aeo-best-practices', + ], + expect.objectContaining({cwd: CWD, stdio: 'pipe'}), + ) + expect(result.succeeded).toBe(true) + expect(result.noOp).toBe(false) + expect(result.updatedSkills).toEqual([ + 'internal-skill', + 'sanity-best-practices', + 'seo-aeo-best-practices', + ]) + expect(result.error).toBeUndefined() + }) + + test('does not invoke execa when no Sanity skills are present in the lockfile', async () => { + mockReadFile.mockResolvedValue(lockfile({'foreign-skill': {source: 'mattpocock/skills'}})) + + const result = await runSkillsUpdate({cwd: CWD}) + + expect(mockExeca).not.toHaveBeenCalled() + expect(result.noOp).toBe(true) + expect(result.succeeded).toBe(true) + expect(result.updatedSkills).toEqual([]) + }) + + test('treats a missing lockfile as a no-op', async () => { + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), {code: 'ENOENT'})) + + const result = await runSkillsUpdate({cwd: CWD}) + + expect(mockExeca).not.toHaveBeenCalled() + expect(result.noOp).toBe(true) + expect(result.succeeded).toBe(true) + }) + + test('treats an unparseable lockfile as a no-op', async () => { + mockReadFile.mockResolvedValue('{not valid json') + + const result = await runSkillsUpdate({cwd: CWD}) + + expect(mockExeca).not.toHaveBeenCalled() + expect(result.noOp).toBe(true) + expect(result.succeeded).toBe(true) + }) + + test('returns an error result when the skills CLI fails (does not throw)', async () => { + mockReadFile.mockResolvedValue( + lockfile({'sanity-best-practices': {source: 'sanity-io/agent-toolkit'}}), + ) + const installErr = new Error('skills exited 1') + mockExeca.mockRejectedValue(installErr) + + const result = await runSkillsUpdate({cwd: CWD}) + + expect(result.succeeded).toBe(false) + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.message).toBe('skills exited 1') + }) +}) 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 cb8a56a71..8df7cc146 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -52,6 +52,21 @@ describe('setupSkills', () => { expect(mockExeca).not.toHaveBeenCalled() }) + test('explicit: surfaces a warning when no eligible editors are detected', async () => { + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await setupSkills({ + cwd: PROJECT_DIR, + editors: [editor('Zed')], + explicit: true, + mode: 'auto', + }) + + expect(result.skipped).toBe(true) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + test('mode: auto installs for all eligible editors without prompting', async () => { mockExeca.mockResolvedValue({exitCode: 0}) diff --git a/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts b/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts new file mode 100644 index 000000000..c8d9d0b28 --- /dev/null +++ b/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts @@ -0,0 +1,144 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +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 {SKILLS_BIN_PATH} from './setupSkills.js' + +const debug = subdebug('skills:update') + +const SKILLS_LOCK_FILENAME = 'skills-lock.json' + +/** + * GitHub orgs whose skills we consider "Sanity-owned" and therefore in scope + * for `sanity skills update`. Keep these in sync with the org names actually + * used on github.com. + */ +const SANITY_GITHUB_ORGS = ['sanity-io', 'sanity-labs'] as const + +interface SkillsLockfile { + skills?: Record +} + +/** + * Returns true when a `skills-lock.json` `source` string points at a + * Sanity-owned GitHub repo. Matches the two forms the upstream `skills` + * CLI actually writes — see `getOwnerRepo` + `lockSource` in skills/dist: + * - `org/repo` shorthand (any HTTP(S) GitHub input gets normalized down + * to this) + * - `git@github.com:org/repo.git` (only when the user typed an SSH URL, + * in which case skills keeps the raw URL as the source) + */ +export function isSanityOwnedSource(source: string | undefined): boolean { + if (!source) return false + const normalized = source.trim().toLowerCase() + return SANITY_GITHUB_ORGS.some( + (org) => normalized.startsWith(`${org}/`) || normalized.startsWith(`git@github.com:${org}/`), + ) +} + +interface RunSkillsUpdateOptions { + /** Working directory for the `skills update` invocation. */ + cwd: string +} + +interface RunSkillsUpdateResult { + /** True when there were no Sanity skills installed to update. */ + noOp: boolean + /** Captured stdout from the underlying `skills update` invocation. */ + stdout: string + succeeded: boolean + /** Skill names that were passed to `skills update`. */ + updatedSkills: string[] + + error?: Error +} + +/** + * Reads `skills-lock.json` from `cwd` and returns the names of skills whose + * source points at a Sanity-owned GitHub repo. Returns an empty array when + * the lockfile is missing or unreadable. + */ +async function readSanitySkillNames(cwd: string): Promise { + const lockPath = path.join(cwd, SKILLS_LOCK_FILENAME) + + let raw: string + try { + raw = await fs.readFile(lockPath, 'utf8') + } catch (error) { + debug('No skills-lock.json found at %s (%O)', lockPath, error) + return [] + } + + let parsed: SkillsLockfile + try { + parsed = JSON.parse(raw) as SkillsLockfile + } catch (error) { + debug('Failed to parse %s: %O', lockPath, error) + return [] + } + + const skills = parsed.skills ?? {} + return Object.entries(skills).flatMap(([name, info]) => + isSanityOwnedSource(info?.source) ? [name] : [], + ) +} + +/** + * Runs the bundled `skills update --project -y ` for every + * project skill whose `source` points at a Sanity-owned GitHub org (see + * `SANITY_GITHUB_ORGS`), leaving non-Sanity project skills untouched. + * + * Sources are read from `skills-lock.json`; if the lockfile is missing or + * contains no Sanity skills the call is a no-op. Failures are surfaced as + * warnings and do not throw. + */ +export async function runSkillsUpdate({ + cwd, +}: RunSkillsUpdateOptions): Promise { + const sanitySkills = await readSanitySkillNames(cwd) + + if (sanitySkills.length === 0) { + ux.stdout( + `No Sanity agent skills found in ${SKILLS_LOCK_FILENAME}. Run \`sanity skills add\` to install them first.`, + ) + return {noOp: true, stdout: '', succeeded: true, updatedSkills: []} + } + + const args = [SKILLS_BIN_PATH, 'update', '--project', '-y', ...sanitySkills] + debug('Running: %s %s (cwd: %s)', process.execPath, args.join(' '), cwd) + + try { + const result = await execa(process.execPath, args, {cwd, stdio: 'pipe', timeout: 120_000}) + debug('skills stdout: %s', result.stdout) + debug('skills stderr: %s', result.stderr) + ux.stdout( + `${logSymbols.success} Updated ${sanitySkills.length} Sanity agent skill${sanitySkills.length === 1 ? '' : 's'}: ${sanitySkills.join(', ')}`, + ) + return { + noOp: false, + stdout: result.stdout, + succeeded: true, + updatedSkills: sanitySkills, + } + } catch (error) { + debug('Error updating skills %O', error) + ux.warn(`Could not update 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: toError(error), + noOp: false, + stdout: '', + succeeded: false, + updatedSkills: [], + } + } +} diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index ef53389f7..96f97a8d6 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -32,10 +32,17 @@ interface SetupSkillsOptions { /** Pre-detected editors. When omitted, `detectAvailableEditors()` is called. */ editors?: Editor[] + /** + * Whether the user explicitly requested skills install (e.g. via + * `sanity skills add`). When true, surfaces status messages even when + * there's nothing to do. When false (e.g. called from `sanity init`), + * stays quiet. + */ + explicit?: boolean + /** * - `'auto'`: install for all eligible editors without prompting - * - `'prompt'`: ask the user with a single yes/no (reserved for a future - * `sanity skills add` command — `sanity init` never uses this) + * - `'prompt'`: ask the user with a single yes/no * - `'skip'`: skip skills installation entirely */ mode?: 'auto' | 'prompt' | 'skip' @@ -57,7 +64,7 @@ interface SetupSkillsResult { * never abort `sanity init`. */ export async function setupSkills(options: SetupSkillsOptions): Promise { - const {cwd, mode = 'prompt'} = options + const {cwd, explicit = false, mode = 'prompt'} = options const empty: SetupSkillsResult = {installedAgents: [], installedForEditors: [], skipped: true} if (mode === 'skip') { @@ -74,6 +81,11 @@ export async function setupSkills(options: SetupSkillsOptions): Promise vi.fn()) +const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(false)) + +vi.mock('../../../actions/skills/setupSkills.js', () => ({ + setupSkills: mockSetupSkills, +})) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isInteractive: mockIsInteractive, + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('skills add', () => { + test('runs setupSkills in auto mode when non-interactive', async () => { + mockSetupSkills.mockResolvedValue({ + installedAgents: ['cursor'], + installedForEditors: ['Cursor'], + skipped: false, + }) + + const {error} = await testCommand(AddSkillsCommand, []) + + if (error) throw error + + expect(setupSkills).toHaveBeenCalledWith( + expect.objectContaining({explicit: true, mode: 'auto'}), + ) + }) + + test('runs setupSkills in prompt mode when interactive', async () => { + mockIsInteractive.mockReturnValueOnce(true) + mockSetupSkills.mockResolvedValue({ + installedAgents: ['claude-code'], + installedForEditors: ['Claude Code'], + skipped: false, + }) + + const {error} = await testCommand(AddSkillsCommand, []) + + if (error) throw error + + expect(setupSkills).toHaveBeenCalledWith( + expect.objectContaining({explicit: true, mode: 'prompt'}), + ) + }) + + test('passes process.cwd() as cwd', async () => { + mockSetupSkills.mockResolvedValue({ + installedAgents: [], + installedForEditors: [], + skipped: true, + }) + + const {error} = await testCommand(AddSkillsCommand, []) + + if (error) throw error + + expect(setupSkills).toHaveBeenCalledWith(expect.objectContaining({cwd: process.cwd()})) + }) + + test('does not throw when setupSkills returns an error result', async () => { + mockSetupSkills.mockResolvedValue({ + error: new Error('install failed'), + installedAgents: [], + installedForEditors: [], + skipped: false, + }) + + const {error} = await testCommand(AddSkillsCommand, []) + + expect(error).toBeUndefined() + }) + + test('surfaces unexpected errors via this.error', async () => { + mockSetupSkills.mockRejectedValue(new Error('boom')) + + const {error} = await testCommand(AddSkillsCommand, []) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('boom') + }) +}) diff --git a/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts b/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts new file mode 100644 index 000000000..c769a8eef --- /dev/null +++ b/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts @@ -0,0 +1,55 @@ +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {runSkillsUpdate} from '../../../actions/skills/runSkillsUpdate.js' +import {UpdateSkillsCommand} from '../update.js' + +const mockRunSkillsUpdate = vi.hoisted(() => vi.fn()) + +vi.mock('../../../actions/skills/runSkillsUpdate.js', () => ({ + runSkillsUpdate: mockRunSkillsUpdate, +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('skills update', () => { + test('invokes runSkillsUpdate with process.cwd()', async () => { + mockRunSkillsUpdate.mockResolvedValue({ + noOp: false, + stdout: '', + succeeded: true, + updatedSkills: ['sanity-best-practices'], + }) + + const {error} = await testCommand(UpdateSkillsCommand, []) + + if (error) throw error + + expect(runSkillsUpdate).toHaveBeenCalledWith({cwd: process.cwd()}) + }) + + test('does not throw when runSkillsUpdate returns an error result', async () => { + mockRunSkillsUpdate.mockResolvedValue({ + error: new Error('skills exited 1'), + noOp: false, + stdout: '', + succeeded: false, + updatedSkills: [], + }) + + const {error} = await testCommand(UpdateSkillsCommand, []) + + expect(error).toBeUndefined() + }) + + test('surfaces unexpected errors via this.error', async () => { + mockRunSkillsUpdate.mockRejectedValue(new Error('boom')) + + const {error} = await testCommand(UpdateSkillsCommand, []) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('boom') + }) +}) diff --git a/packages/@sanity/cli/src/commands/skills/add.ts b/packages/@sanity/cli/src/commands/skills/add.ts new file mode 100644 index 000000000..c4648b751 --- /dev/null +++ b/packages/@sanity/cli/src/commands/skills/add.ts @@ -0,0 +1,47 @@ +import {isInteractive, SanityCommand, subdebug} from '@sanity/cli-core' + +import {setupSkills} from '../../actions/skills/setupSkills.js' +import {SkillsAddTrace} from '../../telemetry/skills.telemetry.js' +import {getErrorMessage, toError} from '../../util/getErrorMessage.js' + +const debug = subdebug('skills:add') + +export class AddSkillsCommand extends SanityCommand { + static override description = + 'Install Sanity agent skills into the current project for detected AI editors (Antigravity, Claude Code, Cline, Cline CLI, Codex CLI, Cursor, Gemini CLI, GitHub Copilot CLI, OpenCode, VS Code, VS Code Insiders)' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %>', + description: 'Install Sanity agent skills for detected AI editors', + }, + ] + + public async run(): Promise { + const trace = this.telemetry.trace(SkillsAddTrace) + trace.start() + + try { + const result = await setupSkills({ + cwd: process.cwd(), + explicit: true, + mode: isInteractive() ? 'prompt' : 'auto', + }) + + trace.log({ + installedAgents: result.installedAgents, + installedForEditors: result.installedForEditors, + }) + + if (result.error) { + trace.error(result.error) + } else { + trace.complete() + } + } catch (error) { + debug('Unexpected error in skills add: %O', error) + trace.error(toError(error)) + this.error(getErrorMessage(error), {exit: 1}) + } + } +} diff --git a/packages/@sanity/cli/src/commands/skills/update.ts b/packages/@sanity/cli/src/commands/skills/update.ts new file mode 100644 index 000000000..29f513a8b --- /dev/null +++ b/packages/@sanity/cli/src/commands/skills/update.ts @@ -0,0 +1,39 @@ +import {SanityCommand, subdebug} from '@sanity/cli-core' + +import {runSkillsUpdate} from '../../actions/skills/runSkillsUpdate.js' +import {SkillsUpdateTrace} from '../../telemetry/skills.telemetry.js' +import {getErrorMessage, toError} from '../../util/getErrorMessage.js' + +const debug = subdebug('skills:update') + +export class UpdateSkillsCommand extends SanityCommand { + static override description = 'Update Sanity agent skills in the current project to the latest' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %>', + description: 'Refresh installed Sanity agent skills using the bundled skills CLI', + }, + ] + + public async run(): Promise { + const trace = this.telemetry.trace(SkillsUpdateTrace) + trace.start() + + try { + const result = await runSkillsUpdate({cwd: process.cwd()}) + + trace.log({succeeded: result.succeeded}) + + if (result.error) { + trace.error(result.error) + } else { + trace.complete() + } + } catch (error) { + debug('Unexpected error in skills update: %O', error) + trace.error(toError(error)) + this.error(getErrorMessage(error), {exit: 1}) + } + } +} diff --git a/packages/@sanity/cli/src/telemetry/skills.telemetry.ts b/packages/@sanity/cli/src/telemetry/skills.telemetry.ts new file mode 100644 index 000000000..3e643e3e4 --- /dev/null +++ b/packages/@sanity/cli/src/telemetry/skills.telemetry.ts @@ -0,0 +1,24 @@ +import {defineTrace} from '@sanity/telemetry' + +interface SkillsAddTraceData { + /** `--agent` values passed to the bundled `skills add` */ + installedAgents: string[] + /** Editor display names that received skills (e.g. "Cursor", "Claude Code") */ + installedForEditors: string[] +} + +export const SkillsAddTrace = defineTrace({ + description: 'User ran `sanity skills add`', + name: 'CLI Skills Add Completed', + version: 1, +}) + +interface SkillsUpdateTraceData { + succeeded: boolean +} + +export const SkillsUpdateTrace = defineTrace({ + description: 'User ran `sanity skills update`', + name: 'CLI Skills Update Completed', + version: 1, +}) From ec5d6cc4a4ca76770c92fb12b01cddc2971d428a Mon Sep 17 00:00:00 2001 From: James Woods Date: Thu, 21 May 2026 15:54:31 +0100 Subject: [PATCH 5/9] chore(skills): address review feedback - Mention the new `sanity skills add` / `sanity skills update` commands in the changeset. - Drop the stale `npx skills add` reference in the init flow comment. - Soften the `skills update` no-op message so it reads correctly whether the lockfile is missing or only contains non-Sanity skills. --- .changeset/pr-1079.md | 2 +- packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts | 2 +- packages/@sanity/cli/src/commands/init.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index bc5cb396f..6a6049441 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -2,4 +2,4 @@ '@sanity/cli': minor --- -Configure agent skills as part of `sanity init` +Configure agent skills as part of `sanity init`, and add the `sanity skills add` and `sanity skills update` commands for installing and refreshing Sanity agent skills in existing projects diff --git a/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts b/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts index c8d9d0b28..a6bb05079 100644 --- a/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts +++ b/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts @@ -104,7 +104,7 @@ export async function runSkillsUpdate({ if (sanitySkills.length === 0) { ux.stdout( - `No Sanity agent skills found in ${SKILLS_LOCK_FILENAME}. Run \`sanity skills add\` to install them first.`, + `No official Sanity skills to update. Run \`sanity skills add\` to install Sanity agent skills.`, ) return {noOp: true, stdout: '', succeeded: true, updatedSkills: []} } diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index ec02788f9..0e5e98346 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -223,7 +223,7 @@ export class InitCommand extends SanityCommand { } // Mirror MCP's environment gating: skip skills install in non-production - // Sanity envs so e2e / UI tests don't shell out to `npx skills add`. + // 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' From 3bea22ccfbbce91af6464bbf17bd5c4baf76960e Mon Sep 17 00:00:00 2001 From: James Woods Date: Fri, 22 May 2026 10:28:10 +0100 Subject: [PATCH 6/9] test(skills): accept Windows path separators in SKILLS_BIN_PATH assertion `fileURLToPath` returns backslash-separated paths on Windows, so the regex needs `[\\/]` instead of a literal `/` to match on both platforms. --- .../cli/src/actions/skills/__tests__/setupSkills.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8df7cc146..53ca5fc35 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -187,6 +187,7 @@ describe('setupSkills', () => { }) test('resolves SKILLS_BIN_PATH to a path that points at the bundled cli', () => { - expect(SKILLS_BIN_PATH).toMatch(/skills\/bin\/cli\.mjs$/) + // Accept both POSIX and Windows path separators + expect(SKILLS_BIN_PATH).toMatch(/skills[\\/]bin[\\/]cli\.mjs$/) }) }) From 61275bf0f5cd751833b1e7c471c6a2216f5ed869 Mon Sep 17 00:00:00 2001 From: James Woods Date: Wed, 27 May 2026 20:25:07 +0100 Subject: [PATCH 7/9] feat(skills): wip re-architect skill install around combined MCP+skill prompt --- packages/@sanity/cli/oclif.config.js | 1 - .../cli/scripts/check-topic-aliases.ts | 1 - .../cli/src/actions/init/initAction.ts | 15 +- .../mcp/__tests__/promptForMCPSetup.test.ts | 72 +++- .../actions/mcp/__tests__/setupMCP.test.ts | 326 +++++++++++++++--- .../cli/src/actions/mcp/editorConfigs.ts | 23 ++ .../cli/src/actions/mcp/promptForMCPSetup.ts | 45 ++- .../@sanity/cli/src/actions/mcp/setupMCP.ts | 268 ++++++++++---- packages/@sanity/cli/src/actions/mcp/types.ts | 7 + .../skills/__tests__/readSkillState.test.ts | 90 +++++ .../skills/__tests__/runSkillsUpdate.test.ts | 150 -------- .../skills/__tests__/setupSkills.test.ts | 189 +++------- .../actions/skills/promptForSkillsSetup.ts | 15 - .../cli/src/actions/skills/readSkillState.ts | 67 ++++ .../cli/src/actions/skills/runSkillsUpdate.ts | 144 -------- .../cli/src/actions/skills/setupSkills.ts | 99 ++---- .../init/init.authentication.test.ts | 2 +- .../__tests__/init/init.bootstrap-app.test.ts | 13 +- .../init/init.create-new-project.test.ts | 4 +- .../init/init.get-project-details.test.ts | 2 +- .../__tests__/init/init.nextjs.test.ts | 2 +- .../commands/__tests__/init/init.plan.test.ts | 2 +- .../__tests__/init/init.staging-env.test.ts | 4 +- .../@sanity/cli/src/commands/mcp/configure.ts | 6 +- .../src/commands/skills/__tests__/add.test.ts | 95 ----- .../commands/skills/__tests__/update.test.ts | 55 --- .../@sanity/cli/src/commands/skills/add.ts | 47 --- .../@sanity/cli/src/commands/skills/update.ts | 39 --- .../cli/src/telemetry/init.telemetry.ts | 2 - .../cli/src/telemetry/skills.telemetry.ts | 24 -- 30 files changed, 860 insertions(+), 949 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/skills/__tests__/readSkillState.test.ts delete mode 100644 packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts delete mode 100644 packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts create mode 100644 packages/@sanity/cli/src/actions/skills/readSkillState.ts delete mode 100644 packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts delete mode 100644 packages/@sanity/cli/src/commands/skills/__tests__/add.test.ts delete mode 100644 packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts delete mode 100644 packages/@sanity/cli/src/commands/skills/add.ts delete mode 100644 packages/@sanity/cli/src/commands/skills/update.ts delete mode 100644 packages/@sanity/cli/src/telemetry/skills.telemetry.ts diff --git a/packages/@sanity/cli/oclif.config.js b/packages/@sanity/cli/oclif.config.js index 98fb6357e..7c2245123 100644 --- a/packages/@sanity/cli/oclif.config.js +++ b/packages/@sanity/cli/oclif.config.js @@ -27,7 +27,6 @@ export default { openapi: {description: 'Manage OpenAPI specifications'}, projects: {description: 'Manage Sanity projects'}, schemas: {description: 'Manage and validate schemas'}, - skills: {description: 'Install and update Sanity agent skills'}, telemetry: {description: 'Manage telemetry consent'}, tokens: {description: 'Manage API tokens for your project'}, users: {description: 'Manage project users and invitations'}, diff --git a/packages/@sanity/cli/scripts/check-topic-aliases.ts b/packages/@sanity/cli/scripts/check-topic-aliases.ts index 0103cdd78..e9f4b9bfe 100644 --- a/packages/@sanity/cli/scripts/check-topic-aliases.ts +++ b/packages/@sanity/cli/scripts/check-topic-aliases.ts @@ -35,7 +35,6 @@ const knownTopicsWithoutAliases: Set = new Set([ 'mcp', 'media', 'openapi', - 'skills', 'telemetry', ]) diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index 75d34bff4..776cc3cd2 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -196,7 +196,11 @@ export async function initAction(options: InitOptions, context: InitContext): Pr ? [] : await detectAvailableEditors() - const mcpResult = await setupMCP({editors: detectedEditors, mode: options.mcpMode}) + const mcpResult = await setupMCP({ + editors: detectedEditors, + mode: options.mcpMode, + skillsMode: options.skillsMode, + }) trace.log({ configuredEditors: mcpResult.configuredEditors, @@ -210,16 +214,11 @@ export async function initAction(options: InitOptions, context: InitContext): Pr const mcpConfigured = mcpResult.configuredEditors async function installSkills(): Promise { - if (options.skillsMode === 'skip') return + if (mcpResult.skillsToInstall.length === 0) return try { - const skillsResult = await setupSkills({ - cwd: outputPath, - editors: detectedEditors, - mode: 'auto', - }) + const skillsResult = await setupSkills({agents: mcpResult.skillsToInstall}) trace.log({ installedAgents: skillsResult.installedAgents, - installedForEditors: skillsResult.installedForEditors, skipped: skillsResult.skipped, step: 'skillsSetup', }) 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 eac7c603f..78c342886 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -8,6 +8,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 mockReadSkillState = vi.hoisted(() => vi.fn()) vi.mock('../detectAvailableEditors.js', () => ({ detectAvailableEditors: mockDetectAvailableEditors, @@ -30,100 +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(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual(['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(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) + expect(result.skipped).toBe(true) + expect(result.alreadyConfiguredEditors).toEqual(['Zed']) + expect(result.skillsToInstall).toEqual([]) }) - test('defaults to prompt mode when options provided without mode', async () => { - const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}] + // ------------------------------------------------------------------------- + // 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) - mockValidateEditorTokens.mockResolvedValue(undefined) - mockPromptForMCPSetup.mockResolvedValue(editors) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([{action: 'mcp-and-skill', editor: editors[0]}]) - await setupMCP({explicit: true}) + await setupMCP({mode: 'prompt', skillsMode: 'prompt'}) - expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) + expect(mockPromptForMCPSetup).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Configure Sanity MCP and install agent skills for these editors?', + }), + ) }) - test('uses caller-provided editors without re-detecting', async () => { - const editors: Editor[] = [{configPath: '/tmp/cursor', configured: false, name: 'Cursor'}] - mockValidateEditorTokens.mockResolvedValue(undefined) - mockPromptForMCPSetup.mockResolvedValue(editors) - mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + 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({editors, mode: 'auto'}) + const result = await setupMCP({mode: 'prompt', skillsMode: 'auto'}) - expect(mockDetectAvailableEditors).not.toHaveBeenCalled() + expect(mockPromptForMCPSetup).toHaveBeenCalled() expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.skillsToInstall).toEqual(['cursor']) }) - test('returns skipped when all detected editors are already configured', async () => { - const editors = [{authStatus: 'valid', configured: true, existingToken: 'tok', name: '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([{action: 'skill-only', editor: editors[0]}]) - const result = await setupMCP({mode: 'auto'}) + 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) - expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + }) + + // ------------------------------------------------------------------------- + // 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'}), + ]) + + const result = await setupMCP({mode: 'auto', skillsMode: 'auto'}) + + 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 f524103e3..1ed0d258a 100644 --- a/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts +++ b/packages/@sanity/cli/src/actions/mcp/editorConfigs.ts @@ -401,3 +401,26 @@ export function getSkillsCliAgent(editorName: EditorName): string | undefined { 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 569b9d006..291acdca0 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -3,9 +3,16 @@ 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' @@ -14,6 +21,8 @@ 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. @@ -36,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 { @@ -44,25 +61,108 @@ 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} + }) +} + +/** + * 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 - * Opt-out by default: runs automatically unless skip option is set + * 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, } } @@ -81,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`) } @@ -103,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__/runSkillsUpdate.test.ts b/packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts deleted file mode 100644 index 5cb2562cc..000000000 --- a/packages/@sanity/cli/src/actions/skills/__tests__/runSkillsUpdate.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import fs from 'node:fs/promises' - -import {afterEach, describe, expect, test, vi} from 'vitest' - -import {isSanityOwnedSource, runSkillsUpdate} from '../runSkillsUpdate.js' -import {SKILLS_BIN_PATH} from '../setupSkills.js' - -const mockExeca = vi.hoisted(() => vi.fn()) -const mockReadFile = vi.hoisted(() => vi.fn()) - -vi.mock('execa', () => ({ - execa: mockExeca, -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - default: { - ...actual, - readFile: mockReadFile, - }, - } -}) - -const CWD = '/tmp/project' - -function lockfile(skills: Record): string { - return JSON.stringify({ - skills: Object.fromEntries( - Object.entries(skills).map(([name, info]) => [ - name, - {source: info.source, sourceType: info.sourceType ?? 'github'}, - ]), - ), - version: 1, - }) -} - -describe('isSanityOwnedSource', () => { - test.each([ - ['sanity-io/agent-toolkit', true], - ['sanity-io/next-sanity', true], - ['sanity-labs/internal-skills', true], - ['git@github.com:sanity-labs/internal-skills.git', true], - ['git@github.com:sanity-io/agent-toolkit.git', true], - ['SANITY-IO/agent-toolkit', true], - [' sanity-io/agent-toolkit ', true], - - ['vercel-labs/skills', false], - ['mattpocock/skills', false], - ['avdlee/swiftui-agent-skill', false], - ['not-sanity-io/foo', false], - ['sanity-io-fake/foo', false], - ['', false], - [undefined, false], - ])('isSanityOwnedSource(%j) === %j', (input, expected) => { - expect(isSanityOwnedSource(input)).toBe(expected) - }) -}) - -describe('runSkillsUpdate', () => { - afterEach(() => { - vi.clearAllMocks() - }) - - test('invokes `skills update --project -y` with every Sanity-owned skill from the lockfile', async () => { - mockReadFile.mockResolvedValue( - lockfile({ - 'foreign-skill': {source: 'mattpocock/skills'}, - 'internal-skill': { - source: 'git@github.com:sanity-labs/internal-skills.git', - sourceType: 'git', - }, - 'sanity-best-practices': {source: 'sanity-io/agent-toolkit'}, - 'seo-aeo-best-practices': {source: 'sanity-io/agent-toolkit'}, - }), - ) - mockExeca.mockResolvedValue({exitCode: 0, stdout: 'updated 3 skills'}) - - const result = await runSkillsUpdate({cwd: CWD}) - - expect(mockExeca).toHaveBeenCalledWith( - process.execPath, - [ - SKILLS_BIN_PATH, - 'update', - '--project', - '-y', - 'internal-skill', - 'sanity-best-practices', - 'seo-aeo-best-practices', - ], - expect.objectContaining({cwd: CWD, stdio: 'pipe'}), - ) - expect(result.succeeded).toBe(true) - expect(result.noOp).toBe(false) - expect(result.updatedSkills).toEqual([ - 'internal-skill', - 'sanity-best-practices', - 'seo-aeo-best-practices', - ]) - expect(result.error).toBeUndefined() - }) - - test('does not invoke execa when no Sanity skills are present in the lockfile', async () => { - mockReadFile.mockResolvedValue(lockfile({'foreign-skill': {source: 'mattpocock/skills'}})) - - const result = await runSkillsUpdate({cwd: CWD}) - - expect(mockExeca).not.toHaveBeenCalled() - expect(result.noOp).toBe(true) - expect(result.succeeded).toBe(true) - expect(result.updatedSkills).toEqual([]) - }) - - test('treats a missing lockfile as a no-op', async () => { - mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), {code: 'ENOENT'})) - - const result = await runSkillsUpdate({cwd: CWD}) - - expect(mockExeca).not.toHaveBeenCalled() - expect(result.noOp).toBe(true) - expect(result.succeeded).toBe(true) - }) - - test('treats an unparseable lockfile as a no-op', async () => { - mockReadFile.mockResolvedValue('{not valid json') - - const result = await runSkillsUpdate({cwd: CWD}) - - expect(mockExeca).not.toHaveBeenCalled() - expect(result.noOp).toBe(true) - expect(result.succeeded).toBe(true) - }) - - test('returns an error result when the skills CLI fails (does not throw)', async () => { - mockReadFile.mockResolvedValue( - lockfile({'sanity-best-practices': {source: 'sanity-io/agent-toolkit'}}), - ) - const installErr = new Error('skills exited 1') - mockExeca.mockRejectedValue(installErr) - - const result = await runSkillsUpdate({cwd: CWD}) - - expect(result.succeeded).toBe(false) - expect(result.error).toBeInstanceOf(Error) - expect(result.error?.message).toBe('skills exited 1') - }) -}) 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 53ca5fc35..e460dc5f0 100644 --- a/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts +++ b/packages/@sanity/cli/src/actions/skills/__tests__/setupSkills.test.ts @@ -1,168 +1,109 @@ import {afterEach, describe, expect, test, vi} from 'vitest' -import {type Editor} from '../../mcp/types.js' -import {SANITY_SKILLS_REPO, setupSkills, SKILLS_BIN_PATH} from '../setupSkills.js' +import { + SANITY_SKILL_NAME, + SANITY_SKILLS_REPO, + setupSkills, + SKILLS_BIN_PATH, +} from '../setupSkills.js' const mockExeca = vi.hoisted(() => vi.fn()) -const mockDetectAvailableEditors = vi.hoisted(() => vi.fn()) -const mockPromptForSkillsSetup = vi.hoisted(() => vi.fn()) vi.mock('execa', () => ({ execa: mockExeca, })) -vi.mock('../../mcp/detectAvailableEditors.js', () => ({ - detectAvailableEditors: mockDetectAvailableEditors, -})) - -vi.mock('../promptForSkillsSetup.js', () => ({ - promptForSkillsSetup: mockPromptForSkillsSetup, -})) - -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 detecting or prompting', async () => { - const result = await setupSkills({cwd: PROJECT_DIR, mode: 'skip'}) + test('returns skipped when no agents are passed', async () => { + const result = await setupSkills({agents: []}) - expect(result).toEqual({installedAgents: [], installedForEditors: [], skipped: true}) - expect(mockDetectAvailableEditors).not.toHaveBeenCalled() - expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() + expect(result).toEqual({installedAgents: [], skipped: true}) expect(mockExeca).not.toHaveBeenCalled() }) - test('skips when no detected editors have a skills agent mapping', async () => { - // Zed and MCPorter do not have a skillsCliAgent mapping - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Zed'), editor('MCPorter')], - mode: 'auto', - }) + test('installs globally for a single agent', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) - expect(result).toEqual({installedAgents: [], installedForEditors: [], skipped: true}) - expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() - expect(mockExeca).not.toHaveBeenCalled() - }) + const result = await setupSkills({agents: ['cursor']}) - test('explicit: surfaces a warning when no eligible editors are detected', async () => { - const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Zed')], - explicit: true, - mode: 'auto', - }) - - expect(result.skipped).toBe(true) - expect(warnSpy).toHaveBeenCalled() - warnSpy.mockRestore() - }) - - test('mode: auto installs for all eligible editors without prompting', async () => { - mockExeca.mockResolvedValue({exitCode: 0}) - - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Cursor'), editor('Claude Code')], - mode: 'auto', - }) - - expect(mockPromptForSkillsSetup).not.toHaveBeenCalled() expect(mockExeca).toHaveBeenCalledWith( process.execPath, [ SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, - '--project', + '--skill', + SANITY_SKILL_NAME, + '-g', '-a', 'cursor', - '-a', - 'claude-code', '-y', ], - expect.objectContaining({cwd: PROJECT_DIR, stdio: 'pipe'}), + expect.objectContaining({stdio: 'pipe', timeout: 90_000}), ) - expect(result.installedAgents).toEqual(['cursor', 'claude-code']) - expect(result.installedForEditors).toEqual(['Cursor', 'Claude Code']) - expect(result.skipped).toBe(false) - expect(result.error).toBeUndefined() - }) - - test('mode: prompt asks the user with a single confirm', async () => { - mockExeca.mockResolvedValue({exitCode: 0}) - mockPromptForSkillsSetup.mockResolvedValue(true) - - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Cursor'), editor('Zed'), editor('Claude Code')], - mode: 'prompt', - }) - - expect(mockPromptForSkillsSetup).toHaveBeenCalledTimes(1) - expect(result.installedAgents).toEqual(['cursor', 'claude-code']) - expect(result.skipped).toBe(false) + expect(result).toEqual({installedAgents: ['cursor'], skipped: false}) }) - test('mode: prompt returns skipped when the user declines', async () => { - mockPromptForSkillsSetup.mockResolvedValue(false) + test('deduplicates repeated agent IDs', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Cursor')], - mode: 'prompt', - }) + const result = await setupSkills({agents: ['cline', 'cline', 'cursor']}) - expect(mockExeca).not.toHaveBeenCalled() - expect(result.skipped).toBe(true) - expect(result.installedAgents).toEqual([]) - }) - - test('deduplicates agents (Cline and Cline CLI map to the same agent)', async () => { - mockExeca.mockResolvedValue({exitCode: 0}) - - const result = await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('Cline'), editor('Cline CLI')], - mode: 'auto', - }) - - expect(result.installedAgents).toEqual(['cline']) + expect(result.installedAgents).toEqual(['cline', 'cursor']) expect(mockExeca).toHaveBeenCalledWith( process.execPath, - [SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, '--project', '-a', 'cline', '-y'], + [ + SKILLS_BIN_PATH, + 'add', + SANITY_SKILLS_REPO, + '--skill', + SANITY_SKILL_NAME, + '-g', + '-a', + 'cline', + '-a', + 'cursor', + '-y', + ], expect.any(Object), ) }) - test('detects editors when not provided by caller', async () => { - mockExeca.mockResolvedValue({exitCode: 0}) - mockDetectAvailableEditors.mockResolvedValue([editor('Cursor')]) + test('passes every agent as a separate -a flag', async () => { + mockExeca.mockResolvedValue({exitCode: 0, stderr: '', stdout: ''}) - await setupSkills({cwd: PROJECT_DIR, mode: 'auto'}) + await setupSkills({agents: ['cursor', 'claude-code', 'github-copilot']}) - expect(mockDetectAvailableEditors).toHaveBeenCalled() - expect(mockExeca).toHaveBeenCalled() + 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({ - cwd: PROJECT_DIR, - editors: [editor('Cursor')], - mode: 'auto', - }) + const result = await setupSkills({agents: ['cursor']}) expect(result.skipped).toBe(false) expect(result.installedAgents).toEqual([]) @@ -170,22 +111,6 @@ describe('setupSkills', () => { expect(result.error?.message).toBe('skills exited 1') }) - test('VS Code maps to github-copilot agent', async () => { - mockExeca.mockResolvedValue({exitCode: 0}) - - await setupSkills({ - cwd: PROJECT_DIR, - editors: [editor('VS Code'), editor('VS Code Insiders')], - mode: 'auto', - }) - - expect(mockExeca).toHaveBeenCalledWith( - process.execPath, - [SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, '--project', '-a', 'github-copilot', '-y'], - expect.any(Object), - ) - }) - 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/promptForSkillsSetup.ts b/packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts deleted file mode 100644 index 67c2957e7..000000000 --- a/packages/@sanity/cli/src/actions/skills/promptForSkillsSetup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {confirm} from '@sanity/cli-core/ux' - -/** - * Prompt the user with a single yes/no for installing Sanity agent skills. - * - * Skills are project-local files, so we don't ask per-editor — if the user - * says yes, skills are installed for every detected editor that has a - * skills CLI mapping. - */ -export async function promptForSkillsSetup(): Promise { - return await confirm({ - default: true, - message: 'Install Sanity agent skills into this project?', - }) -} 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/runSkillsUpdate.ts b/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts deleted file mode 100644 index a6bb05079..000000000 --- a/packages/@sanity/cli/src/actions/skills/runSkillsUpdate.ts +++ /dev/null @@ -1,144 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' - -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 {SKILLS_BIN_PATH} from './setupSkills.js' - -const debug = subdebug('skills:update') - -const SKILLS_LOCK_FILENAME = 'skills-lock.json' - -/** - * GitHub orgs whose skills we consider "Sanity-owned" and therefore in scope - * for `sanity skills update`. Keep these in sync with the org names actually - * used on github.com. - */ -const SANITY_GITHUB_ORGS = ['sanity-io', 'sanity-labs'] as const - -interface SkillsLockfile { - skills?: Record -} - -/** - * Returns true when a `skills-lock.json` `source` string points at a - * Sanity-owned GitHub repo. Matches the two forms the upstream `skills` - * CLI actually writes — see `getOwnerRepo` + `lockSource` in skills/dist: - * - `org/repo` shorthand (any HTTP(S) GitHub input gets normalized down - * to this) - * - `git@github.com:org/repo.git` (only when the user typed an SSH URL, - * in which case skills keeps the raw URL as the source) - */ -export function isSanityOwnedSource(source: string | undefined): boolean { - if (!source) return false - const normalized = source.trim().toLowerCase() - return SANITY_GITHUB_ORGS.some( - (org) => normalized.startsWith(`${org}/`) || normalized.startsWith(`git@github.com:${org}/`), - ) -} - -interface RunSkillsUpdateOptions { - /** Working directory for the `skills update` invocation. */ - cwd: string -} - -interface RunSkillsUpdateResult { - /** True when there were no Sanity skills installed to update. */ - noOp: boolean - /** Captured stdout from the underlying `skills update` invocation. */ - stdout: string - succeeded: boolean - /** Skill names that were passed to `skills update`. */ - updatedSkills: string[] - - error?: Error -} - -/** - * Reads `skills-lock.json` from `cwd` and returns the names of skills whose - * source points at a Sanity-owned GitHub repo. Returns an empty array when - * the lockfile is missing or unreadable. - */ -async function readSanitySkillNames(cwd: string): Promise { - const lockPath = path.join(cwd, SKILLS_LOCK_FILENAME) - - let raw: string - try { - raw = await fs.readFile(lockPath, 'utf8') - } catch (error) { - debug('No skills-lock.json found at %s (%O)', lockPath, error) - return [] - } - - let parsed: SkillsLockfile - try { - parsed = JSON.parse(raw) as SkillsLockfile - } catch (error) { - debug('Failed to parse %s: %O', lockPath, error) - return [] - } - - const skills = parsed.skills ?? {} - return Object.entries(skills).flatMap(([name, info]) => - isSanityOwnedSource(info?.source) ? [name] : [], - ) -} - -/** - * Runs the bundled `skills update --project -y ` for every - * project skill whose `source` points at a Sanity-owned GitHub org (see - * `SANITY_GITHUB_ORGS`), leaving non-Sanity project skills untouched. - * - * Sources are read from `skills-lock.json`; if the lockfile is missing or - * contains no Sanity skills the call is a no-op. Failures are surfaced as - * warnings and do not throw. - */ -export async function runSkillsUpdate({ - cwd, -}: RunSkillsUpdateOptions): Promise { - const sanitySkills = await readSanitySkillNames(cwd) - - if (sanitySkills.length === 0) { - ux.stdout( - `No official Sanity skills to update. Run \`sanity skills add\` to install Sanity agent skills.`, - ) - return {noOp: true, stdout: '', succeeded: true, updatedSkills: []} - } - - const args = [SKILLS_BIN_PATH, 'update', '--project', '-y', ...sanitySkills] - debug('Running: %s %s (cwd: %s)', process.execPath, args.join(' '), cwd) - - try { - const result = await execa(process.execPath, args, {cwd, stdio: 'pipe', timeout: 120_000}) - debug('skills stdout: %s', result.stdout) - debug('skills stderr: %s', result.stderr) - ux.stdout( - `${logSymbols.success} Updated ${sanitySkills.length} Sanity agent skill${sanitySkills.length === 1 ? '' : 's'}: ${sanitySkills.join(', ')}`, - ) - return { - noOp: false, - stdout: result.stdout, - succeeded: true, - updatedSkills: sanitySkills, - } - } catch (error) { - debug('Error updating skills %O', error) - ux.warn(`Could not update 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: toError(error), - noOp: false, - stdout: '', - succeeded: false, - updatedSkills: [], - } - } -} diff --git a/packages/@sanity/cli/src/actions/skills/setupSkills.ts b/packages/@sanity/cli/src/actions/skills/setupSkills.ts index 96f97a8d6..e69a3549f 100644 --- a/packages/@sanity/cli/src/actions/skills/setupSkills.ts +++ b/packages/@sanity/cli/src/actions/skills/setupSkills.ts @@ -6,16 +6,15 @@ import {logSymbols} from '@sanity/cli-core/ux' import {execa} from 'execa' import {getErrorMessage, toError} from '../../util/getErrorMessage.js' -import {detectAvailableEditors} from '../mcp/detectAvailableEditors.js' -import {getSkillsCliAgent} from '../mcp/editorConfigs.js' -import {type Editor} from '../mcp/types.js' -import {promptForSkillsSetup} from './promptForSkillsSetup.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 @@ -26,101 +25,50 @@ export const SKILLS_BIN_PATH = fileURLToPath( ) interface SetupSkillsOptions { - /** Working directory for the `skills add` invocation. Must already exist. */ - cwd: string - - /** Pre-detected editors. When omitted, `detectAvailableEditors()` is called. */ - editors?: Editor[] - - /** - * Whether the user explicitly requested skills install (e.g. via - * `sanity skills add`). When true, surfaces status messages even when - * there's nothing to do. When false (e.g. called from `sanity init`), - * stays quiet. - */ - explicit?: boolean - - /** - * - `'auto'`: install for all eligible editors without prompting - * - `'prompt'`: ask the user with a single yes/no - * - `'skip'`: skip skills installation entirely - */ - mode?: 'auto' | 'prompt' | 'skip' + /** Skills-CLI agent IDs (e.g. 'cursor', 'claude-code') to install for. */ + agents: string[] } interface SetupSkillsResult { - /** `--agent` values passed to `skills add` */ + /** Deduplicated `--agent` values passed to `skills add`. */ installedAgents: string[] - installedForEditors: string[] skipped: boolean error?: Error } /** - * Runs the bundled `skills add` for every detected editor with a mapped - * skills agent, scoped to the project (`--project`). Failures are surfaced - * as warnings and do not throw — skills install is best-effort and must - * never abort `sanity init`. + * 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 {cwd, explicit = false, mode = 'prompt'} = options - const empty: SetupSkillsResult = {installedAgents: [], installedForEditors: [], skipped: true} + const uniqueAgents = [...new Set(options.agents)] - if (mode === 'skip') { - skillsDebug('Skipping skills setup (mode: skip)') - return empty - } - - const editors = options.editors ?? (await detectAvailableEditors()) - - const eligible = editors.flatMap((editor) => { - const agent = getSkillsCliAgent(editor.name) - return agent ? [{agent, editor}] : [] - }) - - if (eligible.length === 0) { - skillsDebug('No detected editors have a skills agent mapping — skipping') - if (explicit) { - ux.warn( - "Couldn't detect any AI editors with skills support. Skills are installed alongside detected editor configs (Claude Code, Cursor, Codex, etc.).", - ) - } - return empty - } - - const uniqueAgents = [...new Set(eligible.map((e) => e.agent))] - const editorLabels = [...new Set(eligible.map((e) => e.editor.name))] - - if (mode === 'prompt') { - const confirmed = await promptForSkillsSetup() - if (!confirmed) { - ux.stdout('Agent skills installation skipped') - return empty - } + if (uniqueAgents.length === 0) { + skillsDebug('No agents passed — skipping skills install') + return {installedAgents: [], skipped: true} } const args = [ SKILLS_BIN_PATH, 'add', SANITY_SKILLS_REPO, - '--project', + '--skill', + SANITY_SKILL_NAME, + '-g', ...uniqueAgents.flatMap((agent) => ['-a', agent]), '-y', ] - skillsDebug('Running: %s %s (cwd: %s)', process.execPath, args.join(' '), cwd) + skillsDebug('Running: %s %s', process.execPath, args.join(' ')) try { - const result = await execa(process.execPath, args, {cwd, stdio: 'pipe', timeout: 90_000}) + 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 ${editorLabels.join(', ')}`) - return { - installedAgents: uniqueAgents, - installedForEditors: editorLabels, - skipped: false, - } + 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) @@ -130,11 +78,6 @@ export async function setupSkills(options: SetupSkillsOptions): Promise ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) @@ -87,7 +88,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: true, }), })) 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 3d6ca163b..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 @@ -92,6 +92,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + skillsToInstall: ['cursor'], skipped: false, }), })) @@ -102,9 +103,8 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: mocks.setupSkills.mockResolvedValue({ - installedAgents: [], - installedForEditors: [], - skipped: true, + installedAgents: ['cursor'], + skipped: false, }), })) @@ -221,10 +221,9 @@ describe('#init: bootstrap-app-initialization', () => { expect(stdout).toContain('npx sanity manage') expect(stdout).toContain('npx sanity help') - // Skills install runs in 'auto' mode after scaffolding has completed. - expect(mocks.setupSkills).toHaveBeenCalledWith( - expect.objectContaining({cwd: convertToSystemPath('/test/output'), mode: 'auto'}), - ) + // 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) 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 1d761eede..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,6 +109,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) @@ -120,7 +121,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: true, }), })) @@ -362,6 +362,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code'], configuredEditors: [], detectedEditors: ['VS Code'], + skillsToInstall: [], skipped: true, }) @@ -413,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 4de6d6913..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,6 +89,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) @@ -100,7 +101,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: true, }), })) 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 846c857fe..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,6 +112,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: ['Cursor'], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) @@ -123,7 +124,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: true, }), })) 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 f169c3d75..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,6 +88,7 @@ vi.mock('../../../actions/mcp/setupMCP.js', () => ({ configuredEditors: [], detectedEditors: [], error: undefined, + skillsToInstall: [], skipped: false, }), })) @@ -99,7 +100,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: 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 683068f02..784c7806e 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, + skillsToInstall: [], skipped: false, }), })) @@ -102,7 +103,6 @@ vi.mock('../../../actions/mcp/detectAvailableEditors.js', () => ({ vi.mock('../../../actions/skills/setupSkills.js', () => ({ setupSkills: vi.fn().mockResolvedValue({ installedAgents: [], - installedForEditors: [], skipped: true, }), })) @@ -310,6 +310,6 @@ describe('#init: staging env propagation', () => { }, ) - expect(setupMCP).toHaveBeenCalledWith({editors: [], mode: 'skip'}) + expect(setupMCP).toHaveBeenCalledWith({editors: [], mode: 'skip', skillsMode: 'skip'}) }) }) 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 vi.fn()) -const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../actions/skills/setupSkills.js', () => ({ - setupSkills: mockSetupSkills, -})) - -vi.mock('@sanity/cli-core', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - isInteractive: mockIsInteractive, - } -}) - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('skills add', () => { - test('runs setupSkills in auto mode when non-interactive', async () => { - mockSetupSkills.mockResolvedValue({ - installedAgents: ['cursor'], - installedForEditors: ['Cursor'], - skipped: false, - }) - - const {error} = await testCommand(AddSkillsCommand, []) - - if (error) throw error - - expect(setupSkills).toHaveBeenCalledWith( - expect.objectContaining({explicit: true, mode: 'auto'}), - ) - }) - - test('runs setupSkills in prompt mode when interactive', async () => { - mockIsInteractive.mockReturnValueOnce(true) - mockSetupSkills.mockResolvedValue({ - installedAgents: ['claude-code'], - installedForEditors: ['Claude Code'], - skipped: false, - }) - - const {error} = await testCommand(AddSkillsCommand, []) - - if (error) throw error - - expect(setupSkills).toHaveBeenCalledWith( - expect.objectContaining({explicit: true, mode: 'prompt'}), - ) - }) - - test('passes process.cwd() as cwd', async () => { - mockSetupSkills.mockResolvedValue({ - installedAgents: [], - installedForEditors: [], - skipped: true, - }) - - const {error} = await testCommand(AddSkillsCommand, []) - - if (error) throw error - - expect(setupSkills).toHaveBeenCalledWith(expect.objectContaining({cwd: process.cwd()})) - }) - - test('does not throw when setupSkills returns an error result', async () => { - mockSetupSkills.mockResolvedValue({ - error: new Error('install failed'), - installedAgents: [], - installedForEditors: [], - skipped: false, - }) - - const {error} = await testCommand(AddSkillsCommand, []) - - expect(error).toBeUndefined() - }) - - test('surfaces unexpected errors via this.error', async () => { - mockSetupSkills.mockRejectedValue(new Error('boom')) - - const {error} = await testCommand(AddSkillsCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('boom') - }) -}) diff --git a/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts b/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts deleted file mode 100644 index c769a8eef..000000000 --- a/packages/@sanity/cli/src/commands/skills/__tests__/update.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {testCommand} from '@sanity/cli-test' -import {afterEach, describe, expect, test, vi} from 'vitest' - -import {runSkillsUpdate} from '../../../actions/skills/runSkillsUpdate.js' -import {UpdateSkillsCommand} from '../update.js' - -const mockRunSkillsUpdate = vi.hoisted(() => vi.fn()) - -vi.mock('../../../actions/skills/runSkillsUpdate.js', () => ({ - runSkillsUpdate: mockRunSkillsUpdate, -})) - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('skills update', () => { - test('invokes runSkillsUpdate with process.cwd()', async () => { - mockRunSkillsUpdate.mockResolvedValue({ - noOp: false, - stdout: '', - succeeded: true, - updatedSkills: ['sanity-best-practices'], - }) - - const {error} = await testCommand(UpdateSkillsCommand, []) - - if (error) throw error - - expect(runSkillsUpdate).toHaveBeenCalledWith({cwd: process.cwd()}) - }) - - test('does not throw when runSkillsUpdate returns an error result', async () => { - mockRunSkillsUpdate.mockResolvedValue({ - error: new Error('skills exited 1'), - noOp: false, - stdout: '', - succeeded: false, - updatedSkills: [], - }) - - const {error} = await testCommand(UpdateSkillsCommand, []) - - expect(error).toBeUndefined() - }) - - test('surfaces unexpected errors via this.error', async () => { - mockRunSkillsUpdate.mockRejectedValue(new Error('boom')) - - const {error} = await testCommand(UpdateSkillsCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('boom') - }) -}) diff --git a/packages/@sanity/cli/src/commands/skills/add.ts b/packages/@sanity/cli/src/commands/skills/add.ts deleted file mode 100644 index c4648b751..000000000 --- a/packages/@sanity/cli/src/commands/skills/add.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {isInteractive, SanityCommand, subdebug} from '@sanity/cli-core' - -import {setupSkills} from '../../actions/skills/setupSkills.js' -import {SkillsAddTrace} from '../../telemetry/skills.telemetry.js' -import {getErrorMessage, toError} from '../../util/getErrorMessage.js' - -const debug = subdebug('skills:add') - -export class AddSkillsCommand extends SanityCommand { - static override description = - 'Install Sanity agent skills into the current project for detected AI editors (Antigravity, Claude Code, Cline, Cline CLI, Codex CLI, Cursor, Gemini CLI, GitHub Copilot CLI, OpenCode, VS Code, VS Code Insiders)' - - static override examples = [ - { - command: '<%= config.bin %> <%= command.id %>', - description: 'Install Sanity agent skills for detected AI editors', - }, - ] - - public async run(): Promise { - const trace = this.telemetry.trace(SkillsAddTrace) - trace.start() - - try { - const result = await setupSkills({ - cwd: process.cwd(), - explicit: true, - mode: isInteractive() ? 'prompt' : 'auto', - }) - - trace.log({ - installedAgents: result.installedAgents, - installedForEditors: result.installedForEditors, - }) - - if (result.error) { - trace.error(result.error) - } else { - trace.complete() - } - } catch (error) { - debug('Unexpected error in skills add: %O', error) - trace.error(toError(error)) - this.error(getErrorMessage(error), {exit: 1}) - } - } -} diff --git a/packages/@sanity/cli/src/commands/skills/update.ts b/packages/@sanity/cli/src/commands/skills/update.ts deleted file mode 100644 index 29f513a8b..000000000 --- a/packages/@sanity/cli/src/commands/skills/update.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {SanityCommand, subdebug} from '@sanity/cli-core' - -import {runSkillsUpdate} from '../../actions/skills/runSkillsUpdate.js' -import {SkillsUpdateTrace} from '../../telemetry/skills.telemetry.js' -import {getErrorMessage, toError} from '../../util/getErrorMessage.js' - -const debug = subdebug('skills:update') - -export class UpdateSkillsCommand extends SanityCommand { - static override description = 'Update Sanity agent skills in the current project to the latest' - - static override examples = [ - { - command: '<%= config.bin %> <%= command.id %>', - description: 'Refresh installed Sanity agent skills using the bundled skills CLI', - }, - ] - - public async run(): Promise { - const trace = this.telemetry.trace(SkillsUpdateTrace) - trace.start() - - try { - const result = await runSkillsUpdate({cwd: process.cwd()}) - - trace.log({succeeded: result.succeeded}) - - if (result.error) { - trace.error(result.error) - } else { - trace.complete() - } - } catch (error) { - debug('Unexpected error in skills update: %O', error) - trace.error(toError(error)) - this.error(getErrorMessage(error), {exit: 1}) - } - } -} diff --git a/packages/@sanity/cli/src/telemetry/init.telemetry.ts b/packages/@sanity/cli/src/telemetry/init.telemetry.ts index 204513390..6c6bd1457 100644 --- a/packages/@sanity/cli/src/telemetry/init.telemetry.ts +++ b/packages/@sanity/cli/src/telemetry/init.telemetry.ts @@ -90,8 +90,6 @@ interface MCPSetupStep { interface SkillsSetupStep { /** `--agent` values that received the Sanity skills bundle */ installedAgents: string[] - /** Editor display names that received skills (e.g. "Cursor", "Claude Code") */ - installedForEditors: string[] skipped: boolean step: 'skillsSetup' } diff --git a/packages/@sanity/cli/src/telemetry/skills.telemetry.ts b/packages/@sanity/cli/src/telemetry/skills.telemetry.ts deleted file mode 100644 index 3e643e3e4..000000000 --- a/packages/@sanity/cli/src/telemetry/skills.telemetry.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {defineTrace} from '@sanity/telemetry' - -interface SkillsAddTraceData { - /** `--agent` values passed to the bundled `skills add` */ - installedAgents: string[] - /** Editor display names that received skills (e.g. "Cursor", "Claude Code") */ - installedForEditors: string[] -} - -export const SkillsAddTrace = defineTrace({ - description: 'User ran `sanity skills add`', - name: 'CLI Skills Add Completed', - version: 1, -}) - -interface SkillsUpdateTraceData { - succeeded: boolean -} - -export const SkillsUpdateTrace = defineTrace({ - description: 'User ran `sanity skills update`', - name: 'CLI Skills Update Completed', - version: 1, -}) From 951b4e99631d95192a0d30c8b3cfc680723bb609 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Fri, 29 May 2026 14:57:58 -0400 Subject: [PATCH 8/9] fix: changeset text, skills flag description --- .changeset/pr-1079.md | 2 +- packages/@sanity/cli/src/commands/init.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index 6a6049441..c7c4f30ba 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -2,4 +2,4 @@ '@sanity/cli': minor --- -Configure agent skills as part of `sanity init`, and add the `sanity skills add` and `sanity skills update` commands for installing and refreshing Sanity agent skills in existing projects +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. \ No newline at end of file diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 0e5e98346..097caa624 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -182,7 +182,7 @@ export class InitCommand extends SanityCommand { skills: Flags.boolean({ allowNo: true, default: true, - description: 'Install Sanity agent skills into the project', + description: 'Install Sanity agent skills globally for detecated AI editors', }), template: Flags.string({ description: 'Project template to use [default: "clean"]', From 5c00bb8cd3eac08d14c4fb9a4fb8c55e4c90cd0b Mon Sep 17 00:00:00 2001 From: James Woods Date: Mon, 1 Jun 2026 12:11:44 +0100 Subject: [PATCH 9/9] fix(init): install agent skills on the --env path --- .changeset/pr-1079.md | 2 +- .../cli/src/actions/init/initAction.ts | 1 + .../__tests__/init/init.staging-env.test.ts | 39 +++++++++++++++++++ packages/@sanity/cli/src/commands/init.ts | 2 +- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.changeset/pr-1079.md b/.changeset/pr-1079.md index c7c4f30ba..8310d0852 100644 --- a/.changeset/pr-1079.md +++ b/.changeset/pr-1079.md @@ -2,4 +2,4 @@ '@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. \ No newline at end of file +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/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index 776cc3cd2..1d798b50a 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -280,6 +280,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr outputPath, }) await writeStagingEnvIfNeeded(output, outputPath) + await installSkills() trace.complete() return } 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 784c7806e..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' @@ -221,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() diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 097caa624..51b441d01 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -182,7 +182,7 @@ export class InitCommand extends SanityCommand { skills: Flags.boolean({ allowNo: true, default: true, - description: 'Install Sanity agent skills globally for detecated AI editors', + description: 'Install Sanity agent skills globally for detected AI editors', }), template: Flags.string({ description: 'Project template to use [default: "clean"]',