From 8669d463b36dbab29539e10aa5a63fbe6c1e9994 Mon Sep 17 00:00:00 2001 From: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Date: Sat, 16 May 2026 17:42:04 +0000 Subject: [PATCH] feat: Add cursor resource (auto-generated from issue #43) --- .../src/__generated__/completions-index.ts | 14 +- docs/resources/(resources)/cursor.mdx | 67 +++++++ src/index.ts | 2 + .../cursor/completions/cursor.extensions.ts | 21 ++ src/resources/cursor/cursor.ts | 189 ++++++++++++++++++ src/resources/cursor/extensions-parameter.ts | 63 ++++++ src/resources/cursor/mcp-servers-parameter.ts | 80 ++++++++ src/resources/cursor/settings-parameter.ts | 75 +++++++ test/cursor/cursor.test.ts | 144 +++++++++++++ 9 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 docs/resources/(resources)/cursor.mdx create mode 100644 src/resources/cursor/completions/cursor.extensions.ts create mode 100644 src/resources/cursor/cursor.ts create mode 100644 src/resources/cursor/extensions-parameter.ts create mode 100644 src/resources/cursor/mcp-servers-parameter.ts create mode 100644 src/resources/cursor/settings-parameter.ts create mode 100644 test/cursor/cursor.test.ts diff --git a/completions-cron/src/__generated__/completions-index.ts b/completions-cron/src/__generated__/completions-index.ts index 26df34b..70379b9 100644 --- a/completions-cron/src/__generated__/completions-index.ts +++ b/completions-cron/src/__generated__/completions-index.ts @@ -14,9 +14,10 @@ import mod9 from '../../../src/resources/javascript/nvm/completions/nvm.nodeVers import mod10 from '../../../src/resources/javascript/npm/completions/npm.install.js'; import mod11 from '../../../src/resources/homebrew/completions/homebrew.formulae.js'; import mod12 from '../../../src/resources/homebrew/completions/homebrew.casks.js'; -import mod13 from '../../../src/resources/asdf/completions/asdf.plugins.js'; -import mod14 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; -import mod15 from '../../../src/resources/apt/completions/apt.install.js'; +import mod13 from '../../../src/resources/cursor/completions/cursor.extensions.js'; +import mod14 from '../../../src/resources/asdf/completions/asdf.plugins.js'; +import mod15 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; +import mod16 from '../../../src/resources/apt/completions/apt.install.js'; export interface CompletionModule { resourceType: string @@ -38,7 +39,8 @@ export const completionModules: CompletionModule[] = [ { resourceType: 'npm', parameterPath: '/install', fetch: mod10 }, { resourceType: 'homebrew', parameterPath: '/formulae', fetch: mod11 }, { resourceType: 'homebrew', parameterPath: '/casks', fetch: mod12 }, - { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod13 }, - { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod14 }, - { resourceType: 'apt', parameterPath: '/install', fetch: mod15 }, + { resourceType: 'cursor', parameterPath: '/extensions', fetch: mod13 }, + { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod14 }, + { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod15 }, + { resourceType: 'apt', parameterPath: '/install', fetch: mod16 }, ] diff --git a/docs/resources/(resources)/cursor.mdx b/docs/resources/(resources)/cursor.mdx new file mode 100644 index 0000000..5177604 --- /dev/null +++ b/docs/resources/(resources)/cursor.mdx @@ -0,0 +1,67 @@ +--- +title: cursor +description: A reference page for the cursor resource +--- + +The cursor resource installs [Cursor](https://cursor.com) — an AI-first code editor built on VS Code — and manages its extensions, editor settings, and MCP (Model Context Protocol) server configuration. + +On **macOS**, Cursor is installed via Homebrew cask (`brew install --cask cursor`). +On **Linux**, Cursor is downloaded as an AppImage to `~/.local/bin/cursor`. + +## Parameters + +- **directory**: *(string)* Installation directory. Defaults to `/Applications` on macOS and `~/.local/bin` on Linux. + +- **extensions**: *(string[])* Cursor extensions to install by ID (e.g. `"ms-python.python"`). Cursor is compatible with most VS Code extensions available on the [Open VSX Registry](https://open-vsx.org). + +- **settings**: *(object)* Editor settings to merge into Cursor's `settings.json`. Uses the same key/value format as VS Code settings. + - macOS path: `~/Library/Application Support/Cursor/User/settings.json` + - Linux path: `~/.config/Cursor/User/settings.json` + +- **mcpServers**: *(object)* MCP servers to configure in `~/.cursor/mcp.json`. Each key is the server name and each value is a server configuration object with: + - `command` *(string, optional)*: The executable to run (e.g. `"npx"`) + - `args` *(string[], optional)*: Arguments to pass to the command + - `env` *(object, optional)*: Environment variables for the server process + - `url` *(string, optional)*: URL for SSE-based remote MCP servers + +## Example usage + +```json title="codify.jsonc" +[ + { + "type": "cursor", + "extensions": ["ms-python.python", "eamodio.gitlens"], + "settings": { + "editor.fontSize": 14, + "editor.formatOnSave": true + } + } +] +``` + +```json title="codify.jsonc" +[ + { + "type": "cursor", + "extensions": ["ms-python.python", "eamodio.gitlens"], + "settings": { + "editor.fontSize": 14, + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + } + } + } + } +] +``` diff --git a/src/index.ts b/src/index.ts index 076647c..7b8bc5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { SshKeyResource } from './resources/ssh/ssh-key.js'; import { TartResource } from './resources/tart/tart.js'; import { TartVmResource } from './resources/tart/tart-vm.js'; import { TerraformResource } from './resources/terraform/terraform.js'; +import { CursorResource } from './resources/cursor/cursor.js'; import { VscodeResource } from './resources/vscode/vscode.js'; import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js'; import { YumResource } from './resources/yum/yum.js'; @@ -78,6 +79,7 @@ runPlugin(Plugin.create( new JenvResource(), new GoenvResource(), new PgcliResource(), + new CursorResource(), new VscodeResource(), new GitRepositoryResource(), new GitRepositoriesResource(), diff --git a/src/resources/cursor/completions/cursor.extensions.ts b/src/resources/cursor/completions/cursor.extensions.ts new file mode 100644 index 0000000..8ae0f6c --- /dev/null +++ b/src/resources/cursor/completions/cursor.extensions.ts @@ -0,0 +1,21 @@ +export default async function loadCursorExtensions(): Promise { + const results: string[] = []; + const pageSize = 200; + + for (let offset = 0; offset < 1000; offset += pageSize) { + const url = `https://open-vsx.org/api/-/search?size=${pageSize}&sortBy=downloadCount&sortOrder=desc&offset=${offset}`; + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) break; + + const data = await response.json() as any; + const extensions = data.extensions as any[] | undefined; + if (!extensions || extensions.length === 0) break; + + results.push(...extensions.map((e: any) => `${e.namespace}.${e.name}` as string)); + } + + return results; +} diff --git a/src/resources/cursor/cursor.ts b/src/resources/cursor/cursor.ts new file mode 100644 index 0000000..f264d7a --- /dev/null +++ b/src/resources/cursor/cursor.ts @@ -0,0 +1,189 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + FileUtils, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ExtensionsParameter } from './extensions-parameter.js'; +import { McpServersParameter } from './mcp-servers-parameter.js'; +import { SettingsParameter } from './settings-parameter.js'; + +export const CURSOR_APPLICATION_NAME = 'Cursor.app'; +export const CURSOR_LOCAL_BIN = path.join(os.homedir(), '.local', 'bin'); +const CURSOR_LOCAL_BIN_EXPORT = `export PATH="${CURSOR_LOCAL_BIN}:$PATH"`; + +export const mcpServerSchema = z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + url: z.string().optional(), +}); +export type McpServer = z.infer; +export type McpServers = Record; + +const schema = z.object({ + directory: z + .string() + .describe('Installation directory. Defaults to /Applications on macOS, ~/.local/bin on Linux.') + .optional(), + extensions: z + .array(z.string()) + .describe('Cursor extensions to install, e.g. ["ms-python.python", "eamodio.gitlens"].') + .optional(), + settings: z + .record(z.string(), z.unknown()) + .describe('Cursor editor settings to merge into settings.json.') + .optional(), + mcpServers: z + .record(z.string(), mcpServerSchema) + .describe('MCP servers to configure in ~/.cursor/mcp.json.') + .optional(), +}); + +export type CursorConfig = z.infer; + +const defaultConfig: Partial = { + extensions: [], +}; + +const exampleAi: ExampleConfig = { + title: 'AI-powered development setup', + description: 'Install Cursor with popular development extensions and editor settings for productive AI-assisted coding.', + configs: [{ + type: 'cursor', + extensions: ['ms-python.python', 'eamodio.gitlens', 'esbenp.prettier-vscode'], + settings: { + 'editor.fontSize': 14, + 'editor.formatOnSave': true, + 'editor.tabSize': 2, + }, + }], +}; + +const exampleWithMcp: ExampleConfig = { + title: 'Cursor with MCP servers', + description: 'Configure Cursor with MCP servers for extended AI capabilities including filesystem and GitHub access.', + configs: [{ + type: 'cursor', + extensions: ['ms-python.python', 'eamodio.gitlens'], + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/projects'], + }, + github: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: ' { + getSettings(): ResourceSettings { + return { + id: 'cursor', + operatingSystems: [OS.Darwin, OS.Linux], + schema, + defaultConfig, + exampleConfigs: { + example1: exampleAi, + example2: exampleWithMcp, + }, + parameterSettings: { + directory: { + type: 'directory', + default: Utils.isMacOS() ? '/Applications' : CURSOR_LOCAL_BIN, + }, + extensions: { type: 'stateful', definition: new ExtensionsParameter(), order: 1 }, + settings: { type: 'stateful', definition: new SettingsParameter(), order: 2 }, + mcpServers: { type: 'stateful', definition: new McpServersParameter(), order: 3 }, + }, + }; + } + + override async refresh(parameters: Partial): Promise | null> { + const isInstalled = await this.isCursorInstalled(parameters.directory); + return isInstalled ? parameters : null; + } + + override async create(plan: CreatePlan): Promise { + if (Utils.isMacOS()) { + await this.installMacOS(); + } else if (Utils.isLinux()) { + await this.installLinux(plan); + } else { + throw new Error('Unsupported operating system'); + } + } + + override async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + + if (Utils.isMacOS()) { + const directory = plan.currentConfig.directory ?? '/Applications'; + await $.spawn(`rm -rf "${path.join(directory, CURSOR_APPLICATION_NAME)}"`); + } else if (Utils.isLinux()) { + const directory = plan.currentConfig.directory ?? CURSOR_LOCAL_BIN; + await $.spawnSafe(`rm -f "${path.join(directory, 'cursor')}"`); + await FileUtils.removeLineFromShellRc(CURSOR_LOCAL_BIN_EXPORT); + } + } + + private async isCursorInstalled(directory?: string | null): Promise { + if (Utils.isMacOS()) { + try { + const files = await fs.readdir(directory ?? '/Applications'); + return files.includes(CURSOR_APPLICATION_NAME); + } catch { + return false; + } + } + + if (Utils.isLinux()) { + const $ = getPty(); + const result = await $.spawnSafe('which cursor'); + return result.status === SpawnStatus.SUCCESS; + } + + return false; + } + + private async installMacOS(): Promise { + const $ = getPty(); + await $.spawn('brew install --cask cursor', { interactive: true }); + } + + private async installLinux(plan: CreatePlan): Promise { + const $ = getPty(); + const isArm = await Utils.isArmArch(); + const downloadUrl = `https://downloader.cursor.sh/linux/appImage/${isArm ? 'arm64' : 'x64'}`; + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cursor-')); + const tmpAppImage = path.join(tmpDir, 'cursor.AppImage'); + + try { + await FileUtils.downloadFile(downloadUrl, tmpAppImage); + const destDir = plan.desiredConfig.directory ?? CURSOR_LOCAL_BIN; + await fs.mkdir(destDir, { recursive: true }); + const destPath = path.join(destDir, 'cursor'); + await fs.rename(tmpAppImage, destPath); + await $.spawn(`chmod +x "${destPath}"`); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + + await FileUtils.addToShellRc(CURSOR_LOCAL_BIN_EXPORT); + } +} diff --git a/src/resources/cursor/extensions-parameter.ts b/src/resources/cursor/extensions-parameter.ts new file mode 100644 index 0000000..dd73978 --- /dev/null +++ b/src/resources/cursor/extensions-parameter.ts @@ -0,0 +1,63 @@ +import { ArrayParameterSetting, Plan, SpawnStatus, StatefulParameter, Utils, getPty } from '@codifycli/plugin-core'; +import os from 'node:os'; +import path from 'node:path'; + +import { CURSOR_APPLICATION_NAME, CURSOR_LOCAL_BIN, CursorConfig } from './cursor.js'; + +function getCursorBinary(directory?: string | null): string { + if (Utils.isMacOS()) { + // On macOS the cursor binary lives inside the app bundle. Use the full path so it + // works immediately after install without requiring a new shell session. + return path.join( + directory ?? '/Applications', + CURSOR_APPLICATION_NAME, + 'Contents', 'Resources', 'app', 'bin', 'cursor', + ); + } + // On Linux, use the full path to the AppImage/binary so it works before PATH is sourced. + return path.join(directory ?? CURSOR_LOCAL_BIN, 'cursor'); +} + +export class ExtensionsParameter extends StatefulParameter { + getSettings(): ArrayParameterSetting { + return { + type: 'array', + isElementEqual(desired, current) { + return desired.toLowerCase() === current.toLowerCase(); + }, + }; + } + + override async refresh(desired: string[] | null, config: Partial): Promise { + const $ = getPty(); + const cursor = getCursorBinary(config.directory); + const result = await $.spawnSafe(`"${cursor}" --list-extensions`); + if (result.status !== SpawnStatus.SUCCESS || result.data == null) { + return null; + } + return result.data.split('\n').filter(Boolean); + } + + async add(valueToAdd: string[], plan: Plan): Promise { + const $ = getPty(); + const cursor = getCursorBinary(plan.desiredConfig?.directory); + for (const ext of valueToAdd) { + await $.spawn(`"${cursor}" --install-extension ${ext} --force`, { interactive: true }); + } + } + + async modify(newValue: string[], previousValue: string[], plan: Plan): Promise { + const toAdd = newValue.filter((n) => !previousValue.some((p) => p.toLowerCase() === n.toLowerCase())); + const toRemove = previousValue.filter((p) => !newValue.some((n) => n.toLowerCase() === p.toLowerCase())); + await this.remove(toRemove, plan); + await this.add(toAdd, plan); + } + + async remove(valueToRemove: string[], plan: Plan): Promise { + const $ = getPty(); + const cursor = getCursorBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory); + for (const ext of valueToRemove) { + await $.spawnSafe(`"${cursor}" --uninstall-extension ${ext}`); + } + } +} diff --git a/src/resources/cursor/mcp-servers-parameter.ts b/src/resources/cursor/mcp-servers-parameter.ts new file mode 100644 index 0000000..be6ce8f --- /dev/null +++ b/src/resources/cursor/mcp-servers-parameter.ts @@ -0,0 +1,80 @@ +import { ParameterSetting, StatefulParameter } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { CursorConfig, McpServers } from './cursor.js'; + +type McpFile = { mcpServers?: McpServers }; + +export class McpServersParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(): Promise { + try { + const content = await fs.readFile(getMcpPath(), 'utf8'); + const parsed = JSON.parse(content) as McpFile; + return parsed.mcpServers ?? null; + } catch { + return null; + } + } + + async add(valueToAdd: McpServers): Promise { + await writeMcpServers(valueToAdd); + } + + async modify(newValue: McpServers, previousValue: McpServers): Promise { + const filePath = getMcpPath(); + let parsed: McpFile = {}; + try { + parsed = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist */ } + + const servers = parsed.mcpServers ?? {}; + + // Remove servers no longer desired + for (const key of Object.keys(previousValue)) { + if (!(key in newValue)) { + delete servers[key]; + } + } + + // Apply new/changed servers + Object.assign(servers, newValue); + + parsed.mcpServers = servers; + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(parsed, null, 2)); + } + + async remove(valueToRemove: McpServers): Promise { + const filePath = getMcpPath(); + try { + const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as McpFile; + const servers = parsed.mcpServers ?? {}; + for (const key of Object.keys(valueToRemove)) { + delete servers[key]; + } + parsed.mcpServers = servers; + await fs.writeFile(filePath, JSON.stringify(parsed, null, 2)); + } catch { /* nothing to do if file doesn't exist */ } + } +} + +function getMcpPath(): string { + return path.join(os.homedir(), '.cursor', 'mcp.json'); +} + +async function writeMcpServers(servers: McpServers): Promise { + const filePath = getMcpPath(); + let parsed: McpFile = {}; + try { + parsed = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist yet */ } + parsed.mcpServers = { ...(parsed.mcpServers ?? {}), ...servers }; + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(parsed, null, 2)); +} diff --git a/src/resources/cursor/settings-parameter.ts b/src/resources/cursor/settings-parameter.ts new file mode 100644 index 0000000..1746af9 --- /dev/null +++ b/src/resources/cursor/settings-parameter.ts @@ -0,0 +1,75 @@ +import { ParameterSetting, StatefulParameter, Utils } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { CursorConfig } from './cursor.js'; + +type Settings = Record; + +export class SettingsParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(): Promise { + try { + const content = await fs.readFile(getSettingsPath(), 'utf8'); + return JSON.parse(content) as Settings; + } catch { + return null; + } + } + + async add(valueToAdd: Settings): Promise { + await writeSettings(valueToAdd); + } + + async modify(newValue: Settings, previousValue: Settings): Promise { + const filePath = getSettingsPath(); + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist */ } + + // Remove keys that were in the previous declaration but are no longer desired + for (const key of Object.keys(previousValue)) { + if (!(key in newValue)) { + delete existing[key]; + } + } + + // Apply all new/changed keys + Object.assign(existing, newValue); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(existing, null, 2)); + } + + async remove(valueToRemove: Settings): Promise { + const filePath = getSettingsPath(); + try { + const existing = JSON.parse(await fs.readFile(filePath, 'utf8')) as Settings; + for (const key of Object.keys(valueToRemove)) { + delete existing[key]; + } + await fs.writeFile(filePath, JSON.stringify(existing, null, 2)); + } catch { /* nothing to do if file doesn't exist */ } + } +} + +function getSettingsPath(): string { + return Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'settings.json') + : path.join(os.homedir(), '.config', 'Cursor', 'User', 'settings.json'); +} + +async function writeSettings(settings: Settings): Promise { + const filePath = getSettingsPath(); + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { /* file may not exist yet */ } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify({ ...existing, ...settings }, null, 2)); +} diff --git a/test/cursor/cursor.test.ts b/test/cursor/cursor.test.ts new file mode 100644 index 0000000..40699fa --- /dev/null +++ b/test/cursor/cursor.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import fs from 'node:fs/promises'; +import * as os from 'node:os'; +import { Utils } from '@codifycli/plugin-core'; + +describe('Cursor integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + // On macOS the cursor binary is inside the app bundle and not on PATH until a new shell is opened. + const cursorBin = Utils.isMacOS() + ? '/Applications/Cursor.app/Contents/Resources/app/bin/cursor' + : path.join(os.homedir(), '.local', 'bin', 'cursor'); + + const settingsFile = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'settings.json') + : path.join(os.homedir(), '.config', 'Cursor', 'User', 'settings.json'); + + const mcpFile = path.join(os.homedir(), '.cursor', 'mcp.json'); + + it('Can install cursor', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'cursor', + }], { + validateApply: async () => { + if (Utils.isMacOS()) { + const lstat = await fs.lstat('/Applications/Cursor.app'); + expect(lstat.isDirectory()).to.be.true; + } else { + const lstat = await fs.lstat(path.join(os.homedir(), '.local', 'bin', 'cursor')); + expect(lstat.isFile()).to.be.true; + } + }, + validateDestroy: async () => { + if (Utils.isMacOS()) { + expect(async () => await fs.lstat('/Applications/Cursor.app')).to.throw; + } + }, + }); + }); + + it('Can manage extensions', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'cursor', + extensions: ['ms-python.python'], + }], { + validateApply: async () => { + const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + expect(data?.toLowerCase()).to.include('ms-python.python'); + }, + testModify: { + modifiedConfigs: [{ + type: 'cursor', + extensions: ['ms-python.python', 'eamodio.gitlens'], + }], + validateModify: async () => { + const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + expect(data?.toLowerCase()).to.include('ms-python.python'); + expect(data?.toLowerCase()).to.include('eamodio.gitlens'); + }, + }, + validateDestroy: async () => { + const { data } = await testSpawn(`"${cursorBin}" --list-extensions`); + expect(data?.toLowerCase()).not.to.include('eamodio.gitlens'); + }, + }); + }); + + it('Can manage settings', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'cursor', + settings: { 'editor.fontSize': 14, 'editor.formatOnSave': true }, + }], { + validateApply: async () => { + const { data } = await testSpawn(`cat "${settingsFile}"`); + const content = JSON.parse(data!); + expect(content['editor.fontSize']).to.equal(14); + expect(content['editor.formatOnSave']).to.be.true; + }, + testModify: { + modifiedConfigs: [{ + type: 'cursor', + settings: { 'editor.fontSize': 16, 'editor.formatOnSave': true }, + }], + validateModify: async () => { + const { data } = await testSpawn(`cat "${settingsFile}"`); + const content = JSON.parse(data!); + expect(content['editor.fontSize']).to.equal(16); + }, + }, + }); + }); + + it('Can manage MCP servers', { timeout: 120000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'cursor', + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + }, + }], { + validateApply: async () => { + const { data } = await testSpawn(`cat "${mcpFile}"`); + const content = JSON.parse(data!); + expect(content.mcpServers).to.have.property('filesystem'); + expect(content.mcpServers.filesystem.command).to.equal('npx'); + }, + testModify: { + modifiedConfigs: [{ + type: 'cursor', + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + github: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + }, + }, + }], + validateModify: async () => { + const { data } = await testSpawn(`cat "${mcpFile}"`); + const content = JSON.parse(data!); + expect(content.mcpServers).to.have.property('filesystem'); + expect(content.mcpServers).to.have.property('github'); + }, + }, + validateDestroy: async () => { + try { + const { data } = await testSpawn(`cat "${mcpFile}"`); + const content = JSON.parse(data!); + expect(content.mcpServers).not.to.have.property('filesystem'); + expect(content.mcpServers).not.to.have.property('github'); + } catch { + // File not existing is also acceptable + } + }, + }); + }); +});