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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions completions-cron/src/__generated__/completions-index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions docs/resources/(resources)/cursor.mdx
Original file line number Diff line number Diff line change
@@ -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": "<your-token>"
}
}
}
}
]
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,6 +79,7 @@ runPlugin(Plugin.create(
new JenvResource(),
new GoenvResource(),
new PgcliResource(),
new CursorResource(),
new VscodeResource(),
new GitRepositoryResource(),
new GitRepositoriesResource(),
Expand Down
21 changes: 21 additions & 0 deletions src/resources/cursor/completions/cursor.extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default async function loadCursorExtensions(): Promise<string[]> {
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;
}
189 changes: 189 additions & 0 deletions src/resources/cursor/cursor.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mcpServerSchema>;
export type McpServers = Record<string, McpServer>;

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<typeof schema>;

const defaultConfig: Partial<CursorConfig> = {
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: '<Replace me here!' },
},
},
}],
};

export class CursorResource extends Resource<CursorConfig> {
getSettings(): ResourceSettings<CursorConfig> {
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<CursorConfig>): Promise<Partial<CursorConfig> | null> {
const isInstalled = await this.isCursorInstalled(parameters.directory);
return isInstalled ? parameters : null;
}

override async create(plan: CreatePlan<CursorConfig>): Promise<void> {
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<CursorConfig>): Promise<void> {
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<boolean> {
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<void> {
const $ = getPty();
await $.spawn('brew install --cask cursor', { interactive: true });
}

private async installLinux(plan: CreatePlan<CursorConfig>): Promise<void> {
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);
}
}
63 changes: 63 additions & 0 deletions src/resources/cursor/extensions-parameter.ts
Original file line number Diff line number Diff line change
@@ -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<CursorConfig, string[]> {
getSettings(): ArrayParameterSetting {
return {
type: 'array',
isElementEqual(desired, current) {
return desired.toLowerCase() === current.toLowerCase();
},
};
}

override async refresh(desired: string[] | null, config: Partial<CursorConfig>): Promise<string[] | null> {
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<CursorConfig>): Promise<void> {
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<CursorConfig>): Promise<void> {
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<CursorConfig>): Promise<void> {
const $ = getPty();
const cursor = getCursorBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory);
for (const ext of valueToRemove) {
await $.spawnSafe(`"${cursor}" --uninstall-extension ${ext}`);
}
}
}
Loading