Skip to content

Commit 3e0ed14

Browse files
feat: Add cursor resource (#44)
* feat: Add cursor resource (auto-generated from issue #43) * chore: bump version number --------- Co-authored-by: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Co-authored-by: kevinwang <kevinwang5658@gmail.com>
1 parent ad1d383 commit 3e0ed14

10 files changed

Lines changed: 650 additions & 7 deletions

File tree

completions-cron/src/__generated__/completions-index.ts

Lines changed: 8 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
title: cursor
3+
description: A reference page for the cursor resource
4+
---
5+
6+
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.
7+
8+
On **macOS**, Cursor is installed via Homebrew cask (`brew install --cask cursor`).
9+
On **Linux**, Cursor is downloaded as an AppImage to `~/.local/bin/cursor`.
10+
11+
## Parameters
12+
13+
- **directory**: *(string)* Installation directory. Defaults to `/Applications` on macOS and `~/.local/bin` on Linux.
14+
15+
- **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).
16+
17+
- **settings**: *(object)* Editor settings to merge into Cursor's `settings.json`. Uses the same key/value format as VS Code settings.
18+
- macOS path: `~/Library/Application Support/Cursor/User/settings.json`
19+
- Linux path: `~/.config/Cursor/User/settings.json`
20+
21+
- **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:
22+
- `command` *(string, optional)*: The executable to run (e.g. `"npx"`)
23+
- `args` *(string[], optional)*: Arguments to pass to the command
24+
- `env` *(object, optional)*: Environment variables for the server process
25+
- `url` *(string, optional)*: URL for SSE-based remote MCP servers
26+
27+
## Example usage
28+
29+
```json title="codify.jsonc"
30+
[
31+
{
32+
"type": "cursor",
33+
"extensions": ["ms-python.python", "eamodio.gitlens"],
34+
"settings": {
35+
"editor.fontSize": 14,
36+
"editor.formatOnSave": true
37+
}
38+
}
39+
]
40+
```
41+
42+
```json title="codify.jsonc"
43+
[
44+
{
45+
"type": "cursor",
46+
"extensions": ["ms-python.python", "eamodio.gitlens"],
47+
"settings": {
48+
"editor.fontSize": 14,
49+
"editor.tabSize": 2,
50+
"editor.formatOnSave": true
51+
},
52+
"mcpServers": {
53+
"filesystem": {
54+
"command": "npx",
55+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
56+
},
57+
"github": {
58+
"command": "npx",
59+
"args": ["-y", "@modelcontextprotocol/server-github"],
60+
"env": {
61+
"GITHUB_PERSONAL_ACCESS_TOKEN": "<your-token>"
62+
}
63+
}
64+
}
65+
}
66+
]
67+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "default",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux",
55
"main": "dist/index.js",
66
"scripts": {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { SshKeyResource } from './resources/ssh/ssh-key.js';
5252
import { TartResource } from './resources/tart/tart.js';
5353
import { TartVmResource } from './resources/tart/tart-vm.js';
5454
import { TerraformResource } from './resources/terraform/terraform.js';
55+
import { CursorResource } from './resources/cursor/cursor.js';
5556
import { VscodeResource } from './resources/vscode/vscode.js';
5657
import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js';
5758
import { YumResource } from './resources/yum/yum.js';
@@ -80,6 +81,7 @@ runPlugin(Plugin.create(
8081
new JenvResource(),
8182
new GoenvResource(),
8283
new PgcliResource(),
84+
new CursorResource(),
8385
new VscodeResource(),
8486
new GitRepositoryResource(),
8587
new GitRepositoriesResource(),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export default async function loadCursorExtensions(): Promise<string[]> {
2+
const results: string[] = [];
3+
const pageSize = 200;
4+
5+
for (let offset = 0; offset < 1000; offset += pageSize) {
6+
const url = `https://open-vsx.org/api/-/search?size=${pageSize}&sortBy=downloadCount&sortOrder=desc&offset=${offset}`;
7+
const response = await fetch(url, {
8+
headers: { Accept: 'application/json' },
9+
});
10+
11+
if (!response.ok) break;
12+
13+
const data = await response.json() as any;
14+
const extensions = data.extensions as any[] | undefined;
15+
if (!extensions || extensions.length === 0) break;
16+
17+
results.push(...extensions.map((e: any) => `${e.namespace}.${e.name}` as string));
18+
}
19+
20+
return results;
21+
}

src/resources/cursor/cursor.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ExampleConfig,
5+
FileUtils,
6+
Resource,
7+
ResourceSettings,
8+
SpawnStatus,
9+
Utils,
10+
getPty,
11+
z,
12+
} from '@codifycli/plugin-core';
13+
import { OS } from '@codifycli/schemas';
14+
import fs from 'node:fs/promises';
15+
import os from 'node:os';
16+
import path from 'node:path';
17+
18+
import { ExtensionsParameter } from './extensions-parameter.js';
19+
import { McpServersParameter } from './mcp-servers-parameter.js';
20+
import { SettingsParameter } from './settings-parameter.js';
21+
22+
export const CURSOR_APPLICATION_NAME = 'Cursor.app';
23+
export const CURSOR_LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
24+
const CURSOR_LOCAL_BIN_EXPORT = `export PATH="${CURSOR_LOCAL_BIN}:$PATH"`;
25+
26+
export const mcpServerSchema = z.object({
27+
command: z.string().optional(),
28+
args: z.array(z.string()).optional(),
29+
env: z.record(z.string(), z.string()).optional(),
30+
url: z.string().optional(),
31+
});
32+
export type McpServer = z.infer<typeof mcpServerSchema>;
33+
export type McpServers = Record<string, McpServer>;
34+
35+
const schema = z.object({
36+
directory: z
37+
.string()
38+
.describe('Installation directory. Defaults to /Applications on macOS, ~/.local/bin on Linux.')
39+
.optional(),
40+
extensions: z
41+
.array(z.string())
42+
.describe('Cursor extensions to install, e.g. ["ms-python.python", "eamodio.gitlens"].')
43+
.optional(),
44+
settings: z
45+
.record(z.string(), z.unknown())
46+
.describe('Cursor editor settings to merge into settings.json.')
47+
.optional(),
48+
mcpServers: z
49+
.record(z.string(), mcpServerSchema)
50+
.describe('MCP servers to configure in ~/.cursor/mcp.json.')
51+
.optional(),
52+
});
53+
54+
export type CursorConfig = z.infer<typeof schema>;
55+
56+
const defaultConfig: Partial<CursorConfig> = {
57+
extensions: [],
58+
};
59+
60+
const exampleAi: ExampleConfig = {
61+
title: 'AI-powered development setup',
62+
description: 'Install Cursor with popular development extensions and editor settings for productive AI-assisted coding.',
63+
configs: [{
64+
type: 'cursor',
65+
extensions: ['ms-python.python', 'eamodio.gitlens', 'esbenp.prettier-vscode'],
66+
settings: {
67+
'editor.fontSize': 14,
68+
'editor.formatOnSave': true,
69+
'editor.tabSize': 2,
70+
},
71+
}],
72+
};
73+
74+
const exampleWithMcp: ExampleConfig = {
75+
title: 'Cursor with MCP servers',
76+
description: 'Configure Cursor with MCP servers for extended AI capabilities including filesystem and GitHub access.',
77+
configs: [{
78+
type: 'cursor',
79+
extensions: ['ms-python.python', 'eamodio.gitlens'],
80+
mcpServers: {
81+
filesystem: {
82+
command: 'npx',
83+
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/projects'],
84+
},
85+
github: {
86+
command: 'npx',
87+
args: ['-y', '@modelcontextprotocol/server-github'],
88+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: '<Replace me here!' },
89+
},
90+
},
91+
}],
92+
};
93+
94+
export class CursorResource extends Resource<CursorConfig> {
95+
getSettings(): ResourceSettings<CursorConfig> {
96+
return {
97+
id: 'cursor',
98+
operatingSystems: [OS.Darwin, OS.Linux],
99+
schema,
100+
defaultConfig,
101+
exampleConfigs: {
102+
example1: exampleAi,
103+
example2: exampleWithMcp,
104+
},
105+
parameterSettings: {
106+
directory: {
107+
type: 'directory',
108+
default: Utils.isMacOS() ? '/Applications' : CURSOR_LOCAL_BIN,
109+
},
110+
extensions: { type: 'stateful', definition: new ExtensionsParameter(), order: 1 },
111+
settings: { type: 'stateful', definition: new SettingsParameter(), order: 2 },
112+
mcpServers: { type: 'stateful', definition: new McpServersParameter(), order: 3 },
113+
},
114+
};
115+
}
116+
117+
override async refresh(parameters: Partial<CursorConfig>): Promise<Partial<CursorConfig> | null> {
118+
const isInstalled = await this.isCursorInstalled(parameters.directory);
119+
return isInstalled ? parameters : null;
120+
}
121+
122+
override async create(plan: CreatePlan<CursorConfig>): Promise<void> {
123+
if (Utils.isMacOS()) {
124+
await this.installMacOS();
125+
} else if (Utils.isLinux()) {
126+
await this.installLinux(plan);
127+
} else {
128+
throw new Error('Unsupported operating system');
129+
}
130+
}
131+
132+
override async destroy(plan: DestroyPlan<CursorConfig>): Promise<void> {
133+
const $ = getPty();
134+
135+
if (Utils.isMacOS()) {
136+
const directory = plan.currentConfig.directory ?? '/Applications';
137+
await $.spawn(`rm -rf "${path.join(directory, CURSOR_APPLICATION_NAME)}"`);
138+
} else if (Utils.isLinux()) {
139+
const directory = plan.currentConfig.directory ?? CURSOR_LOCAL_BIN;
140+
await $.spawnSafe(`rm -f "${path.join(directory, 'cursor')}"`);
141+
await FileUtils.removeLineFromShellRc(CURSOR_LOCAL_BIN_EXPORT);
142+
}
143+
}
144+
145+
private async isCursorInstalled(directory?: string | null): Promise<boolean> {
146+
if (Utils.isMacOS()) {
147+
try {
148+
const files = await fs.readdir(directory ?? '/Applications');
149+
return files.includes(CURSOR_APPLICATION_NAME);
150+
} catch {
151+
return false;
152+
}
153+
}
154+
155+
if (Utils.isLinux()) {
156+
const $ = getPty();
157+
const result = await $.spawnSafe('which cursor');
158+
return result.status === SpawnStatus.SUCCESS;
159+
}
160+
161+
return false;
162+
}
163+
164+
private async installMacOS(): Promise<void> {
165+
const $ = getPty();
166+
await $.spawn('brew install --cask cursor', { interactive: true });
167+
}
168+
169+
private async installLinux(plan: CreatePlan<CursorConfig>): Promise<void> {
170+
const $ = getPty();
171+
const isArm = await Utils.isArmArch();
172+
const downloadUrl = `https://downloader.cursor.sh/linux/appImage/${isArm ? 'arm64' : 'x64'}`;
173+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cursor-'));
174+
const tmpAppImage = path.join(tmpDir, 'cursor.AppImage');
175+
176+
try {
177+
await FileUtils.downloadFile(downloadUrl, tmpAppImage);
178+
const destDir = plan.desiredConfig.directory ?? CURSOR_LOCAL_BIN;
179+
await fs.mkdir(destDir, { recursive: true });
180+
const destPath = path.join(destDir, 'cursor');
181+
await fs.rename(tmpAppImage, destPath);
182+
await $.spawn(`chmod +x "${destPath}"`);
183+
} finally {
184+
await fs.rm(tmpDir, { recursive: true, force: true });
185+
}
186+
187+
await FileUtils.addToShellRc(CURSOR_LOCAL_BIN_EXPORT);
188+
}
189+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ArrayParameterSetting, Plan, SpawnStatus, StatefulParameter, Utils, getPty } from '@codifycli/plugin-core';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
import { CURSOR_APPLICATION_NAME, CURSOR_LOCAL_BIN, CursorConfig } from './cursor.js';
6+
7+
function getCursorBinary(directory?: string | null): string {
8+
if (Utils.isMacOS()) {
9+
// On macOS the cursor binary lives inside the app bundle. Use the full path so it
10+
// works immediately after install without requiring a new shell session.
11+
return path.join(
12+
directory ?? '/Applications',
13+
CURSOR_APPLICATION_NAME,
14+
'Contents', 'Resources', 'app', 'bin', 'cursor',
15+
);
16+
}
17+
// On Linux, use the full path to the AppImage/binary so it works before PATH is sourced.
18+
return path.join(directory ?? CURSOR_LOCAL_BIN, 'cursor');
19+
}
20+
21+
export class ExtensionsParameter extends StatefulParameter<CursorConfig, string[]> {
22+
getSettings(): ArrayParameterSetting {
23+
return {
24+
type: 'array',
25+
isElementEqual(desired, current) {
26+
return desired.toLowerCase() === current.toLowerCase();
27+
},
28+
};
29+
}
30+
31+
override async refresh(desired: string[] | null, config: Partial<CursorConfig>): Promise<string[] | null> {
32+
const $ = getPty();
33+
const cursor = getCursorBinary(config.directory);
34+
const result = await $.spawnSafe(`"${cursor}" --list-extensions`);
35+
if (result.status !== SpawnStatus.SUCCESS || result.data == null) {
36+
return null;
37+
}
38+
return result.data.split('\n').filter(Boolean);
39+
}
40+
41+
async add(valueToAdd: string[], plan: Plan<CursorConfig>): Promise<void> {
42+
const $ = getPty();
43+
const cursor = getCursorBinary(plan.desiredConfig?.directory);
44+
for (const ext of valueToAdd) {
45+
await $.spawn(`"${cursor}" --install-extension ${ext} --force`, { interactive: true });
46+
}
47+
}
48+
49+
async modify(newValue: string[], previousValue: string[], plan: Plan<CursorConfig>): Promise<void> {
50+
const toAdd = newValue.filter((n) => !previousValue.some((p) => p.toLowerCase() === n.toLowerCase()));
51+
const toRemove = previousValue.filter((p) => !newValue.some((n) => n.toLowerCase() === p.toLowerCase()));
52+
await this.remove(toRemove, plan);
53+
await this.add(toAdd, plan);
54+
}
55+
56+
async remove(valueToRemove: string[], plan: Plan<CursorConfig>): Promise<void> {
57+
const $ = getPty();
58+
const cursor = getCursorBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory);
59+
for (const ext of valueToRemove) {
60+
await $.spawnSafe(`"${cursor}" --uninstall-extension ${ext}`);
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)