From 678224c7da4339143707565173699f9c20855997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quy=E1=BB=81n?= Date: Tue, 2 Jun 2026 00:56:18 +0700 Subject: [PATCH] feat: add first-class Cursor IDE install and CLI parity Native .cursor/ layout (rules, skills, commands, architecture) with install, list, status, enable, disable, and selective uninstall support. --- .gitignore | 10 ++ CLAUDE.md | 2 + README.md | 12 +- bin/cli.js | 6 +- .../basic_design/cursor-editor-support.md | 47 ++++++ .../detail_design/cli/cursor-install.md | 107 +++++++++++++ .../system/srs/cursor-install-requirements.md | 46 ++++++ src/commands/disable.ts | 129 ++++++++-------- src/commands/enable.ts | 134 ++++++++-------- src/commands/install/cursor-editor.ts | 145 ++++++++++++++++++ src/commands/install/cursor-transform.ts | 27 ++++ src/commands/install/index.ts | 7 +- src/commands/install/prompts.ts | 5 +- src/commands/install/skill-editor.ts | 14 +- src/commands/install/transform.ts | 9 ++ src/commands/install/usage.ts | 15 +- src/commands/install/write-if-changed.ts | 15 ++ src/commands/list.ts | 53 ++++++- src/commands/postinstall.ts | 3 +- src/commands/status.ts | 68 +++++++- src/commands/uninstall.ts | 112 +++++++++++++- src/utils/editor-constants.ts | 4 + src/utils/editor-items.ts | 92 +++++++++++ src/utils/symlink.ts | 13 +- 24 files changed, 899 insertions(+), 176 deletions(-) create mode 100644 docs/system/basic_design/cursor-editor-support.md create mode 100644 docs/system/detail_design/cli/cursor-install.md create mode 100644 docs/system/srs/cursor-install-requirements.md create mode 100644 src/commands/install/cursor-editor.ts create mode 100644 src/commands/install/cursor-transform.ts create mode 100644 src/commands/install/write-if-changed.ts create mode 100644 src/utils/editor-constants.ts create mode 100644 src/utils/editor-items.ts diff --git a/.gitignore b/.gitignore index cee810a..db3113a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,13 @@ release/ .env.* package-lock.json + +# AI Kit (ai-kit onboard) +openspec/changes/archive/ +.engine +.run +.ai-kit +.fastembed_cache +openspec +.agent +.cursor \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 19888db..b321cd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,8 @@ moicle install # Commands moicle install --global # Symlinks to ~/.claude/ moicle install --project # Copies to ./.claude/ +moicle install --target cursor --global # Cursor rules, commands, skills → ~/.cursor/ +moicle install --target cursor --project # Cursor assets → ./.cursor/ moicle list # List installed moicle status # Show enabled/disabled moicle enable # Enable agent/command/skill diff --git a/README.md b/README.md index 3619316..1b5fb02 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ A toolkit to bootstrap and accelerate project development with Claude Code throu - [x] Claude - [x] Codex CLI - [x] Antigravity -- [ ] Cursor +- [x] Cursor - [ ] Windsurf +Older MoiCle versions merged agents into `~/.cursor/AGENTS.md`. Re-run `moicle install --target cursor` for native `.cursor/rules/*.mdc` layout; you may delete legacy `AGENTS.md` manually. + ## Installation ```bash @@ -46,6 +48,10 @@ moicle install --target codex --global # Install for Antigravity moicle install --target antigravity --global +# Install for Cursor (rules, commands, skills, architecture) +moicle install --target cursor --global +moicle install --target cursor --project + # Choose: # 1. Pick Claude Code, Codex CLI, or Antigravity # 2. Pick global or project scope @@ -60,7 +66,9 @@ moicle install --target antigravity --global | `moicle install --project` | Install to ./.claude/ (copies) | | `moicle install --target codex --global` | Install Codex skills + architecture to ~/.codex/ | | `moicle install --target codex --project` | Install Codex skills + architecture to ./.codex/ | -| `moicle list` | List all installed items | +| `moicle install --target cursor --global` | Install Cursor rules, commands, skills to ~/.cursor/ | +| `moicle install --target cursor --project` | Install Cursor assets to ./.cursor/ | +| `moicle list --target cursor` | List Cursor rules, commands, and skills | | `moicle status` | Show enabled/disabled status | | `moicle enable ` | Enable an agent/command/skill | | `moicle disable ` | Disable an agent/command/skill | diff --git a/bin/cli.js b/bin/cli.js index 1fb7ebf..18dd885 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -46,7 +46,7 @@ program .description('List installed agents, commands, and skills') .option('-g, --global', 'List global installations') .option('-p, --project', 'List project installations') - .option('-t, --target ', 'Target editor (claude, codex, antigravity)') + .option('-t, --target ', 'Target editor (claude, codex, cursor, antigravity)') .action(listCommand); program @@ -60,6 +60,7 @@ program .option('-g, --global', 'Enable in global ~/.claude/') .option('-p, --project', 'Enable in current project ./.claude/') .option('-a, --all', 'Enable all disabled items') + .option('-t, --target ', 'Target editor (claude, codex, cursor, antigravity)') .action(enableCommand); program @@ -68,6 +69,7 @@ program .option('-g, --global', 'Disable in global ~/.claude/') .option('-p, --project', 'Disable in current project ./.claude/') .option('-a, --all', 'Disable all enabled items') + .option('-t, --target ', 'Target editor (claude, codex, cursor, antigravity)') .action(disableCommand); program @@ -75,7 +77,7 @@ program .description('Show enabled/disabled status of all items') .option('-g, --global', 'Show global status') .option('-p, --project', 'Show project status') - .option('-t, --target ', 'Target editor (claude, codex, antigravity)') + .option('-t, --target ', 'Target editor (claude, codex, cursor, antigravity)') .action(statusCommand); program diff --git a/docs/system/basic_design/cursor-editor-support.md b/docs/system/basic_design/cursor-editor-support.md new file mode 100644 index 0000000..70a9fa2 --- /dev/null +++ b/docs/system/basic_design/cursor-editor-support.md @@ -0,0 +1,47 @@ +--- +doc_tier: basic_design +fragment: cursor-editor-support +status: draft +change: moicle-cursor-support +--- + +# Basic Design: Cursor Editor Support (MoiCle) + +## Purpose + +Extend the MoiCle npm CLI so Cursor IDE users receive the same packaged agents, commands, skills, and architecture references currently available to Claude Code, Codex CLI, and Antigravity users. + +## Supported Editors (after change) + +| Editor | Install target | Layout | +|--------|----------------|--------| +| Claude Code | `~/.claude/` or `./.claude/` | Native agents/commands/skills | +| Codex CLI | `~/.codex/` | Flat skills folder | +| Antigravity | `~/.gemini/` | Flat skills folder | +| **Cursor** | `~/.cursor/` or `./.cursor/` | **rules + skills + commands** | +| Windsurf | — | Out of scope | + +## User Workflow + +1. `npm install -g moicle` +2. `moicle install --target cursor --global` (or `--project` for repo-local) +3. Open project in Cursor; use rules, skills, and slash commands from `.cursor/` + +## Capability Map + +| Capability | User value | +|------------|------------| +| cursor-native-install | Full asset tree in Cursor-native paths | +| cursor-path-transform | Docs reference `~/.cursor/` not `~/.claude/` | +| cursor-scoped-install | Global vs project choice | +| cursor-cli-parity | list/status/enable/disable/uninstall | +| cursor-docs | README and help text updated | + +## Documentation Sync Targets + +When this change is merged, update: + +- `README.md` — Current Support `[x] Cursor`, install examples +- `CLAUDE.md` — `--target cursor` in install section +- `AGENTS.md` — Editor matrix if present +- `src/commands/postinstall.ts` — Post-install hints diff --git a/docs/system/detail_design/cli/cursor-install.md b/docs/system/detail_design/cli/cursor-install.md new file mode 100644 index 0000000..385d485 --- /dev/null +++ b/docs/system/detail_design/cli/cursor-install.md @@ -0,0 +1,107 @@ +--- +doc_tier: detail_design +area: cli +feature: cursor-install +status: draft +change: moicle-cursor-support +sync_targets: + - README.md + - CLAUDE.md + - AGENTS.md +--- + +# Detail Design: Cursor Install Pipeline (MoiCle CLI) + +## Overview + +MoiCle installs packaged assets from `assets/` into editor-specific directories. This document describes the Cursor install pipeline introduced by change `moicle-cursor-support`. + +## Directory Layout After Install + +### Global (`~/.cursor/`) + +``` +~/.cursor/ +├── rules/ # 16 agent personas (*.mdc) +├── skills/ # 21 skills (/SKILL.md) +├── commands/ # 4 slash commands (*.md) +└── architecture/ # 11 architecture reference docs (*.md) +``` + +### Project (`./.cursor/`) + +Same subtree relative to `process.cwd()`. + +## Module Responsibilities + +| Module | Layer | Role | +|--------|-------|------| +| `cursor-transform.ts` | Functional core | `buildCursorRuleMdc`, `sanitizeDescription`, path rewrite | +| `transform.ts` | Functional core | `rewriteCursorPaths`, `extractFrontmatter` | +| `cursor-editor.ts` | Imperative shell | Orchestrates install per scope | +| `install/index.ts` | Imperative shell | Registers `cursor` in `ScopedTarget` | +| `symlink.ts` | Imperative shell | `EDITOR_CONFIGS.cursor`, directory helpers | +| `editor-constants.ts` | Shared | `DISABLED_SUFFIX`, `CURSOR_RULE_EXT`, `DESCRIPTION_MAX_LENGTH` | + +## Agent → Rule Transform + +Source: `assets/agents/{developers,utilities}/.md` + +Output: `.cursor/rules/.mdc` + +```yaml +--- +description: +alwaysApply: false +--- + + +``` + +Agents are opt-in rules; they MUST NOT use `alwaysApply: true`. + +## Skills and Commands + +| Source | Output | Transform | +|--------|--------|-------------| +| `assets/skills/**/SKILL.md` | `.cursor/skills//SKILL.md` | `listSkillsNested` + `rewriteCursorPaths` | +| `assets/commands/*.md` | `.cursor/commands/.md` | `rewriteCursorPaths` only | +| `assets/architecture/*.md` | `.cursor/architecture/.md` | `rewriteCursorPaths` | + +## Path Rewrite Table + +| Find | Replace | +|------|---------| +| `~/.claude/` | `~/.cursor/` | +| `.claude/` | `.cursor/` | +| `Claude Code` | `Cursor` | + +## Enable / Disable Conventions + +| Type | Enabled | Disabled | +|------|---------|----------| +| Rule (agent) | `rules/.mdc` | `rules/.mdc.disabled` | +| Command | `commands/.md` | `commands/.md.disabled` | +| Skill | `skills//` | `skills/.disabled/` | + +Config keys in `moicle-config.json` remain `agents`, `commands`, `skills`; `--target cursor` selects filesystem paths. + +## CLI Entry Points + +```bash +moicle install --target cursor [--global|--project|--all] +moicle list --target cursor [--global|--project] +moicle status --target cursor [--global|--project] +moicle enable|disable --target cursor [--global|--project] +moicle uninstall --target cursor [--global|--project] +``` + +## Legacy Behavior + +Prior MoiCle versions called `mergeAgentsToFile` → `~/.cursor/AGENTS.md`. That path is **removed**. Users may manually delete legacy `AGENTS.md` after reinstall. + +## Non-Goals + +- `mcp.json` injection +- Windsurf support +- Symlink install on Windows diff --git a/docs/system/srs/cursor-install-requirements.md b/docs/system/srs/cursor-install-requirements.md new file mode 100644 index 0000000..0767d7e --- /dev/null +++ b/docs/system/srs/cursor-install-requirements.md @@ -0,0 +1,46 @@ +--- +doc_tier: srs +fragment: cursor-install-requirements +status: draft +change: moicle-cursor-support +--- + +# SRS Fragment: Cursor Install Requirements + +## Functional Requirements + +### FR-1 Native install layout + +The MoiCle CLI SHALL install Cursor assets using per-file layout under `.cursor/rules/`, `.cursor/skills/`, `.cursor/commands/`, and `.cursor/architecture/` as specified in `specs/cursor-native-install/spec.md`. + +### FR-2 Path transformation + +The MoiCle CLI SHALL rewrite Claude-centric paths to Cursor paths in all generated files as specified in `specs/cursor-path-transform/spec.md`. + +### FR-3 Scoped installation + +The MoiCle CLI SHALL support global and project installation scopes for Cursor as specified in `specs/cursor-scoped-install/spec.md`. + +### FR-4 CLI parity + +The MoiCle CLI commands `list`, `status`, `enable`, `disable`, and `uninstall` SHALL accept `--target cursor` as specified in `specs/cursor-cli-parity/spec.md`. + +### FR-5 User documentation + +Published package documentation SHALL list Cursor as a supported editor with install examples as specified in `specs/cursor-docs/spec.md`. + +## Non-Functional Requirements + +### NFR-1 Idempotency + +Re-running install without asset changes SHOULD not rewrite unchanged files (byte-identical skip). + +### NFR-2 Windows compatibility + +Install on Windows SHALL use file copy, not symlinks. + +## Out of Scope + +- MCP server configuration for Cursor +- Windsurf editor +- Automatic removal of legacy `AGENTS.md` diff --git a/src/commands/disable.ts b/src/commands/disable.ts index 018726e..c7493d9 100644 --- a/src/commands/disable.ts +++ b/src/commands/disable.ts @@ -2,14 +2,19 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import fs from 'fs'; import path from 'path'; -import type { CommandOptions, ItemType, Scope, SelectableItem } from '../types.js'; +import type { CommandOptions, EditorTarget, ItemType, Scope, SelectableItem } from '../types.js'; import { disableItem, isDisabled } from '../utils/config.js'; +import { DISABLED_SUFFIX } from '../utils/editor-constants.js'; import { - getAgentsDir, - getCommandsDir, - getSkillsDir, - listItems, -} from '../utils/symlink.js'; + cleanItemDisplayName, + getAgentEnabledPath, + getCommandEnabledPath, + getItemDir, + inferItemType, + listCursorRuleItems, + resolveEditorTarget, +} from '../utils/editor-items.js'; +import { listItems } from '../utils/symlink.js'; const printHeader = (): void => { console.log(''); @@ -20,8 +25,8 @@ const printHeader = (): void => { }; const renameToDisabled = (filePath: string): boolean => { - if (!filePath.endsWith('.disabled')) { - const newPath = filePath + '.disabled'; + if (!filePath.endsWith(DISABLED_SUFFIX)) { + const newPath = `${filePath}${DISABLED_SUFFIX}`; try { fs.renameSync(filePath, newPath); return true; @@ -32,33 +37,33 @@ const renameToDisabled = (filePath: string): boolean => { return true; }; -const disableItemByName = (type: ItemType, name: string, scope: Scope = 'global'): boolean => { - let dir: string; - switch (type) { - case 'agents': - dir = getAgentsDir(scope); - break; - case 'commands': - dir = getCommandsDir(scope); - break; - case 'skills': - dir = getSkillsDir(scope); - break; - default: - return false; - } - - const cleanName = name.replace('@', '').replace('.md', '').replace('.disabled', ''); - const enabledPath = path.join(dir, `${cleanName}.md`); - const skillPath = path.join(dir, cleanName); - - if (fs.existsSync(enabledPath)) { - renameToDisabled(enabledPath); - } else if (fs.existsSync(skillPath) && fs.statSync(skillPath).isDirectory()) { - try { - fs.renameSync(skillPath, skillPath + '.disabled'); - } catch { - // ignore +const disableItemByName = ( + type: ItemType, + name: string, + scope: Scope, + target: EditorTarget +): boolean => { + const dir = getItemDir(type, target, scope); + const cleanName = cleanItemDisplayName(name.replace('@', '').replace('/', '')); + + if (type === 'agents') { + const enabledPath = getAgentEnabledPath(target, dir, cleanName); + if (fs.existsSync(enabledPath)) { + renameToDisabled(enabledPath); + } + } else if (type === 'commands') { + const enabledPath = getCommandEnabledPath(dir, cleanName); + if (fs.existsSync(enabledPath)) { + renameToDisabled(enabledPath); + } + } else { + const skillPath = path.join(dir, cleanName); + if (fs.existsSync(skillPath) && fs.statSync(skillPath).isDirectory()) { + try { + fs.renameSync(skillPath, `${skillPath}${DISABLED_SUFFIX}`); + } catch { + // ignore + } } } @@ -68,33 +73,29 @@ const disableItemByName = (type: ItemType, name: string, scope: Scope = 'global' const getEnabledItemsByType = ( type: ItemType, - scope: Scope + scope: Scope, + target: EditorTarget ): SelectableItem[] => { const items: SelectableItem[] = []; - - let dir: string; + const dir = getItemDir(type, target, scope); let prefix = ''; + switch (type) { case 'agents': - dir = getAgentsDir(scope); prefix = '@'; break; case 'commands': - dir = getCommandsDir(scope); prefix = '/'; break; case 'skills': - dir = getSkillsDir(scope); break; - default: - return items; } - const files = listItems(dir); + const files = type === 'agents' && target === 'cursor' ? listCursorRuleItems(dir) : listItems(dir); for (const file of files) { - if (!file.name.endsWith('.disabled')) { - const cleanName = file.name.replace('.md', ''); + if (!file.name.endsWith(DISABLED_SUFFIX)) { + const cleanName = cleanItemDisplayName(file.name); if (!isDisabled(type, cleanName)) { items.push({ type, @@ -109,14 +110,17 @@ const getEnabledItemsByType = ( return items; }; -const getAllEnabledItems = (scope: Scope): Map => { +const getAllEnabledItems = ( + scope: Scope, + target: EditorTarget +): Map => { const itemsByType = new Map(); - itemsByType.set('agents', getEnabledItemsByType('agents', scope)); - if (scope === 'global') { - itemsByType.set('commands', getEnabledItemsByType('commands', scope)); + itemsByType.set('agents', getEnabledItemsByType('agents', scope, target)); + if (scope === 'global' || target === 'cursor') { + itemsByType.set('commands', getEnabledItemsByType('commands', scope, target)); } - itemsByType.set('skills', getEnabledItemsByType('skills', scope)); + itemsByType.set('skills', getEnabledItemsByType('skills', scope, target)); return itemsByType; }; @@ -132,14 +136,15 @@ export const disableCommand = async ( printHeader(); const scope: Scope = options.project ? 'project' : 'global'; + const target = resolveEditorTarget(options); if (options.all) { - const itemsByType = getAllEnabledItems(scope); + const itemsByType = getAllEnabledItems(scope, target); let totalDisabled = 0; for (const [type, items] of itemsByType) { for (const item of items) { - disableItemByName(type, item.name, scope); + disableItemByName(type, item.name, scope, target); console.log(chalk.red(` ✗ Disabled ${item.display}`)); totalDisabled++; } @@ -155,23 +160,13 @@ export const disableCommand = async ( } if (itemName) { - const cleanName = itemName.replace('@', '').replace('/', ''); - let type: ItemType = 'agents'; - if (itemName.startsWith('/')) { - type = 'commands'; - } else if (!itemName.startsWith('@')) { - const skillsDir = getSkillsDir(scope); - if (fs.existsSync(path.join(skillsDir, cleanName))) { - type = 'skills'; - } - } - - disableItemByName(type, cleanName, scope); + const type = inferItemType(itemName, target, scope); + disableItemByName(type, itemName, scope, target); console.log(chalk.yellow(` ✗ Disabled ${itemName}`)); return; } - const itemsByType = getAllEnabledItems(scope); + const itemsByType = getAllEnabledItems(scope, target); const allItems: SelectableItem[] = []; for (const items of itemsByType.values()) { allItems.push(...items); @@ -216,7 +211,7 @@ export const disableCommand = async ( console.log(''); for (const item of selected) { - disableItemByName(item.type, item.name, scope); + disableItemByName(item.type, item.name, scope, target); console.log(chalk.red(` ✗ Disabled ${item.display}`)); } diff --git a/src/commands/enable.ts b/src/commands/enable.ts index c977e4c..a374126 100644 --- a/src/commands/enable.ts +++ b/src/commands/enable.ts @@ -2,14 +2,19 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import fs from 'fs'; import path from 'path'; -import type { CommandOptions, ItemType, Scope, SelectableItem } from '../types.js'; +import type { CommandOptions, EditorTarget, ItemType, Scope, SelectableItem } from '../types.js'; import { enableItem, getDisabledItems } from '../utils/config.js'; +import { DISABLED_SUFFIX } from '../utils/editor-constants.js'; import { - getAgentsDir, - getCommandsDir, - getSkillsDir, - listItems, -} from '../utils/symlink.js'; + cleanItemDisplayName, + getAgentDisabledPath, + getCommandDisabledPath, + getItemDir, + inferItemType, + listCursorRuleItems, + resolveEditorTarget, +} from '../utils/editor-items.js'; +import { listItems } from '../utils/symlink.js'; const printHeader = (): void => { console.log(''); @@ -20,8 +25,8 @@ const printHeader = (): void => { }; const renameToEnabled = (filePath: string): boolean => { - if (filePath.endsWith('.disabled')) { - const newPath = filePath.replace('.disabled', ''); + if (filePath.endsWith(DISABLED_SUFFIX)) { + const newPath = filePath.slice(0, -DISABLED_SUFFIX.length); try { fs.renameSync(filePath, newPath); return true; @@ -32,34 +37,34 @@ const renameToEnabled = (filePath: string): boolean => { return true; }; -const enableItemByName = (type: ItemType, name: string, scope: Scope = 'global'): boolean => { - let dir: string; - switch (type) { - case 'agents': - dir = getAgentsDir(scope); - break; - case 'commands': - dir = getCommandsDir(scope); - break; - case 'skills': - dir = getSkillsDir(scope); - break; - default: - return false; - } - - const cleanName = name.replace('@', '').replace('.md', '').replace('.disabled', ''); - const disabledPath = path.join(dir, `${cleanName}.md.disabled`); - const skillDisabledPath = path.join(dir, cleanName + '.disabled'); - const skillEnabledPath = path.join(dir, cleanName); - - if (fs.existsSync(disabledPath)) { - renameToEnabled(disabledPath); - } else if (fs.existsSync(skillDisabledPath)) { - try { - fs.renameSync(skillDisabledPath, skillEnabledPath); - } catch { - // ignore +const enableItemByName = ( + type: ItemType, + name: string, + scope: Scope, + target: EditorTarget +): boolean => { + const dir = getItemDir(type, target, scope); + const cleanName = cleanItemDisplayName(name.replace('@', '').replace('/', '')); + + if (type === 'agents') { + const disabledPath = getAgentDisabledPath(target, dir, cleanName); + if (fs.existsSync(disabledPath)) { + renameToEnabled(disabledPath); + } + } else if (type === 'commands') { + const disabledPath = getCommandDisabledPath(dir, cleanName); + if (fs.existsSync(disabledPath)) { + renameToEnabled(disabledPath); + } + } else { + const skillDisabledPath = path.join(dir, `${cleanName}${DISABLED_SUFFIX}`); + const skillEnabledPath = path.join(dir, cleanName); + if (fs.existsSync(skillDisabledPath)) { + try { + fs.renameSync(skillDisabledPath, skillEnabledPath); + } catch { + // ignore + } } } @@ -69,34 +74,30 @@ const enableItemByName = (type: ItemType, name: string, scope: Scope = 'global') const getDisabledItemsByType = ( type: ItemType, - scope: Scope + scope: Scope, + target: EditorTarget ): SelectableItem[] => { const items: SelectableItem[] = []; const disabledConfig = getDisabledItems(type); - - let dir: string; + const dir = getItemDir(type, target, scope); let prefix = ''; + switch (type) { case 'agents': - dir = getAgentsDir(scope); prefix = '@'; break; case 'commands': - dir = getCommandsDir(scope); prefix = '/'; break; case 'skills': - dir = getSkillsDir(scope); break; - default: - return items; } - const files = listItems(dir); + const files = type === 'agents' && target === 'cursor' ? listCursorRuleItems(dir) : listItems(dir); for (const file of files) { - const cleanName = file.name.replace('.md', '').replace('.disabled', ''); - const isFileDisabled = file.name.endsWith('.disabled'); + const cleanName = cleanItemDisplayName(file.name); + const isFileDisabled = file.name.endsWith(DISABLED_SUFFIX); const isConfigDisabled = disabledConfig.includes(cleanName); if (isFileDisabled || isConfigDisabled) { @@ -112,14 +113,17 @@ const getDisabledItemsByType = ( return items; }; -const getAllDisabledItems = (scope: Scope): Map => { +const getAllDisabledItems = ( + scope: Scope, + target: EditorTarget +): Map => { const itemsByType = new Map(); - itemsByType.set('agents', getDisabledItemsByType('agents', scope)); - if (scope === 'global') { - itemsByType.set('commands', getDisabledItemsByType('commands', scope)); + itemsByType.set('agents', getDisabledItemsByType('agents', scope, target)); + if (scope === 'global' || target === 'cursor') { + itemsByType.set('commands', getDisabledItemsByType('commands', scope, target)); } - itemsByType.set('skills', getDisabledItemsByType('skills', scope)); + itemsByType.set('skills', getDisabledItemsByType('skills', scope, target)); return itemsByType; }; @@ -135,14 +139,15 @@ export const enableCommand = async ( printHeader(); const scope: Scope = options.project ? 'project' : 'global'; + const target = resolveEditorTarget(options); if (options.all) { - const itemsByType = getAllDisabledItems(scope); + const itemsByType = getAllDisabledItems(scope, target); let totalEnabled = 0; for (const [type, items] of itemsByType) { for (const item of items) { - enableItemByName(type, item.name, scope); + enableItemByName(type, item.name, scope, target); console.log(chalk.green(` ✓ Enabled ${item.display}`)); totalEnabled++; } @@ -158,26 +163,13 @@ export const enableCommand = async ( } if (itemName) { - const cleanName = itemName.replace('@', '').replace('/', ''); - let type: ItemType = 'agents'; - if (itemName.startsWith('/')) { - type = 'commands'; - } else if (!itemName.startsWith('@')) { - const skillsDir = getSkillsDir(scope); - if ( - fs.existsSync(path.join(skillsDir, cleanName)) || - fs.existsSync(path.join(skillsDir, cleanName + '.disabled')) - ) { - type = 'skills'; - } - } - - enableItemByName(type, cleanName, scope); + const type = inferItemType(itemName, target, scope); + enableItemByName(type, itemName, scope, target); console.log(chalk.green(` ✓ Enabled ${itemName}`)); return; } - const itemsByType = getAllDisabledItems(scope); + const itemsByType = getAllDisabledItems(scope, target); const allItems: SelectableItem[] = []; for (const items of itemsByType.values()) { allItems.push(...items); @@ -222,7 +214,7 @@ export const enableCommand = async ( console.log(''); for (const item of selected) { - enableItemByName(item.type, item.name, scope); + enableItemByName(item.type, item.name, scope, target); console.log(chalk.green(` ✓ Enabled ${item.display}`)); } diff --git a/src/commands/install/cursor-editor.ts b/src/commands/install/cursor-editor.ts new file mode 100644 index 0000000..9211d08 --- /dev/null +++ b/src/commands/install/cursor-editor.ts @@ -0,0 +1,145 @@ +import chalk from 'chalk'; +import path from 'path'; +import fs from 'fs'; +import type { FileResult, Scope } from '../../types.js'; +import { + ASSETS_DIR, + ensureDir, + getEditorConfig, + getEditorDir, + getFiles, + listSkillsNested, +} from '../../utils/symlink.js'; +import { CURSOR_RULE_EXT } from '../../utils/editor-constants.js'; +import { printSummary } from './print.js'; +import { extractFrontmatter, rewriteCursorPaths } from './transform.js'; +import { buildCursorRuleMdc } from './cursor-transform.js'; +import { writeIfChanged } from './write-if-changed.js'; + +const installCursorArchitecture = (targetDir: string): FileResult[] => { + const archDir = path.join(ASSETS_DIR, 'architecture'); + const targetArchDir = path.join(targetDir, 'architecture'); + ensureDir(targetArchDir); + + if (!fs.existsSync(archDir)) { + return []; + } + + return getFiles(archDir).map((file) => { + const content = rewriteCursorPaths(fs.readFileSync(file, 'utf-8')); + return writeIfChanged(path.join(targetArchDir, path.basename(file)), content, path.basename(file)); + }); +}; + +const installCursorSkills = (targetDir: string): FileResult[] => { + const results: FileResult[] = []; + const targetSkillsDir = path.join(targetDir, 'skills'); + ensureDir(targetSkillsDir); + + const skillsDir = path.join(ASSETS_DIR, 'skills'); + if (fs.existsSync(skillsDir)) { + for (const skill of listSkillsNested(skillsDir)) { + const skillTargetDir = path.join(targetSkillsDir, skill.name); + ensureDir(skillTargetDir); + + let status: FileResult['status'] = 'created'; + for (const file of getFiles(skill.path, 8)) { + const targetFile = path.join(skillTargetDir, path.relative(skill.path, file)); + ensureDir(path.dirname(targetFile)); + const content = rewriteCursorPaths(fs.readFileSync(file, 'utf-8')); + const existed = fs.existsSync(targetFile); + if (existed && fs.readFileSync(targetFile, 'utf-8') === content) { + status = status === 'created' ? 'exists' : status; + continue; + } + fs.writeFileSync(targetFile, content); + if (existed) { + status = 'updated'; + } + } + results.push({ status, name: skill.name }); + } + } + + return results; +}; + +const installCursorCommands = (targetDir: string): FileResult[] => { + const results: FileResult[] = []; + const commandsDir = path.join(ASSETS_DIR, 'commands'); + const targetCommandsDir = path.join(targetDir, 'commands'); + ensureDir(targetCommandsDir); + + if (!fs.existsSync(commandsDir)) { + return results; + } + + for (const file of getFiles(commandsDir)) { + const name = path.basename(file, '.md'); + const content = rewriteCursorPaths(fs.readFileSync(file, 'utf-8')); + results.push(writeIfChanged(path.join(targetCommandsDir, `${name}.md`), content, name)); + } + + return results; +}; + +const installCursorRules = (targetDir: string): FileResult[] => { + const results: FileResult[] = []; + const rulesDir = path.join(targetDir, 'rules'); + ensureDir(rulesDir); + + for (const dirName of ['developers', 'utilities'] as const) { + const sourceDir = path.join(ASSETS_DIR, 'agents', dirName); + if (!fs.existsSync(sourceDir)) { + continue; + } + + for (const file of getFiles(sourceDir)) { + const name = path.basename(file, '.md'); + const parsed = extractFrontmatter(fs.readFileSync(file, 'utf-8')); + const description = + parsed.description ?? + (dirName === 'developers' + ? `MoiCle developer persona for ${name}. Use when the task matches this stack specialist.` + : `MoiCle utility persona for ${name}. Use when the task matches this specialist.`); + const body = rewriteCursorPaths(parsed.body.trimStart()); + const content = buildCursorRuleMdc(description, body); + results.push(writeIfChanged(path.join(rulesDir, `${name}${CURSOR_RULE_EXT}`), content, name)); + } + } + + return results; +}; + +export const installCursorScope = async (scope: Scope): Promise => { + const isGlobal = scope === 'global'; + const label = isGlobal ? 'Global' : 'Project'; + const name = getEditorConfig('cursor').name; + const baseDir = getEditorDir('cursor', scope); + + console.log(''); + console.log(chalk.cyan(`>>> ${label} ${name} Installation`)); + console.log(chalk.gray(` Target: ${baseDir}`)); + console.log(''); + + ensureDir(baseDir); + + const archResults = installCursorArchitecture(baseDir); + console.log(chalk.green(` ✓ Architecture installed to ${chalk.cyan(path.join(baseDir, 'architecture'))}`)); + printSummary(archResults); + + const ruleResults = installCursorRules(baseDir); + console.log(chalk.green(` ✓ Agent rules installed to ${chalk.cyan(path.join(baseDir, 'rules'))}`)); + printSummary(ruleResults); + + const commandResults = installCursorCommands(baseDir); + console.log(chalk.green(` ✓ Commands installed to ${chalk.cyan(path.join(baseDir, 'commands'))}`)); + printSummary(commandResults); + + const skillResults = installCursorSkills(baseDir); + console.log(chalk.green(` ✓ Skills installed to ${chalk.cyan(path.join(baseDir, 'skills'))}`)); + printSummary(skillResults); + + console.log(''); + console.log(chalk.green(`✓ ${label} ${name} installation complete!`)); +}; diff --git a/src/commands/install/cursor-transform.ts b/src/commands/install/cursor-transform.ts new file mode 100644 index 0000000..e42fb71 --- /dev/null +++ b/src/commands/install/cursor-transform.ts @@ -0,0 +1,27 @@ +import { DESCRIPTION_MAX_LENGTH } from '../../utils/editor-constants.js'; + +export const sanitizeDescription = (raw: string): string => { + const flattened = raw.replace(/\s+/g, ' ').trim(); + if (flattened.length <= DESCRIPTION_MAX_LENGTH) { + return flattened; + } + return flattened.slice(0, DESCRIPTION_MAX_LENGTH - 3) + '...'; +}; + +const formatYamlDescription = (description: string): string => { + const sanitized = sanitizeDescription(description); + if (/[:#"'\n]/.test(sanitized)) { + return `"${sanitized.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return sanitized; +}; + +export const buildCursorRuleMdc = (description: string, body: string): string => { + const yamlDescription = formatYamlDescription(description); + return `--- +description: ${yamlDescription} +alwaysApply: false +--- + +${body}`; +}; diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index e90e02a..661cae6 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -6,14 +6,15 @@ import { addTarget } from '../../utils/config.js'; import { printHeader } from './print.js'; import { installScope } from './native.js'; import { installSkillEditorScope } from './skill-editor.js'; +import { installCursorScope } from './cursor-editor.js'; import { installForOtherEditor } from './generic-editor.js'; import { showTargetMenu, showInteractiveMenu } from './prompts.js'; import { printUsage } from './usage.js'; /** Editors that branch into a global/project/all scope flow. */ -type ScopedTarget = 'claude' | 'codex' | 'antigravity'; +type ScopedTarget = 'claude' | 'codex' | 'cursor' | 'antigravity'; const isScopedTarget = (target: EditorTarget): target is ScopedTarget => - target === 'claude' || target === 'codex' || target === 'antigravity'; + target === 'claude' || target === 'codex' || target === 'cursor' || target === 'antigravity'; const resolveStrategy = (options: CommandOptions): boolean => { if (options.symlink === true) return true; @@ -40,6 +41,8 @@ const installScopedTarget = async ( if (target === 'claude') { // Symlinks only make sense for the global, shared install. await installScope(scope, scope === 'global' ? useSymlink : false); + } else if (target === 'cursor') { + await installCursorScope(scope); } else { await installSkillEditorScope(scope, target); } diff --git a/src/commands/install/prompts.ts b/src/commands/install/prompts.ts index 3673ee4..271fcc8 100644 --- a/src/commands/install/prompts.ts +++ b/src/commands/install/prompts.ts @@ -20,14 +20,15 @@ export const showTargetMenu = async (): Promise => { return target; }; -const SCOPE_PATHS: Record<'claude' | 'codex' | 'antigravity', { global: string; project: string }> = { +const SCOPE_PATHS: Record<'claude' | 'codex' | 'cursor' | 'antigravity', { global: string; project: string }> = { claude: { global: '~/.claude/', project: './.claude/' }, codex: { global: '~/.codex/', project: './.codex/' }, + cursor: { global: '~/.cursor/', project: './.cursor/' }, antigravity: { global: '~/.gemini/', project: './.gemini/' }, }; export const showInteractiveMenu = async ( - target: 'claude' | 'codex' | 'antigravity' + target: 'claude' | 'codex' | 'cursor' | 'antigravity' ): Promise<'global' | 'project' | 'all'> => { const { global: globalPath, project: projectPath } = SCOPE_PATHS[target]; diff --git a/src/commands/install/skill-editor.ts b/src/commands/install/skill-editor.ts index 758d368..2a8a405 100644 --- a/src/commands/install/skill-editor.ts +++ b/src/commands/install/skill-editor.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import type { FileResult, Scope } from '../../types.js'; import { ASSETS_DIR, ensureDir, getEditorConfig, getEditorDir, getFiles, listSkillsNested } from '../../utils/symlink.js'; import { printSummary } from './print.js'; +import { writeIfChanged } from './write-if-changed.js'; import { type SkillEditorTarget, rewriteClaudePaths, @@ -18,19 +19,6 @@ import { * `target` — there is no per-editor branching. */ -/** Write content only if it differs; report created/updated/exists. */ -const writeIfChanged = (targetFile: string, content: string, name: string): FileResult => { - if (fs.existsSync(targetFile)) { - if (fs.readFileSync(targetFile, 'utf-8') === content) { - return { status: 'exists', name }; - } - fs.writeFileSync(targetFile, content); - return { status: 'updated', name }; - } - fs.writeFileSync(targetFile, content); - return { status: 'created', name }; -}; - const ensureSkillDir = (baseDir: string, name: string): string => { const skillDir = path.join(baseDir, name); ensureDir(skillDir); diff --git a/src/commands/install/transform.ts b/src/commands/install/transform.ts index 6522315..41473e3 100644 --- a/src/commands/install/transform.ts +++ b/src/commands/install/transform.ts @@ -7,6 +7,12 @@ /** Skill-based editors that consume rewritten SKILL.md folders. */ export type SkillEditorTarget = 'codex' | 'antigravity'; +const CURSOR_REWRITE_RULES: Array<[RegExp, string]> = [ + [/~\/\.claude\//g, '~/.cursor/'], + [/\.claude\//g, '.cursor/'], + [/Claude Code/g, 'Cursor'], +]; + const REWRITE_RULES: Record> = { codex: [ [/~\/\.claude\//g, '~/.codex/'], @@ -32,6 +38,9 @@ export const rewriteClaudePaths = ( return REWRITE_RULES[target].reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), content); }; +export const rewriteCursorPaths = (content: string): string => + CURSOR_REWRITE_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), content); + export const extractFrontmatter = ( content: string ): { frontmatter: string | null; body: string; description?: string } => { diff --git a/src/commands/install/usage.ts b/src/commands/install/usage.ts index 7c435f3..6363bc8 100644 --- a/src/commands/install/usage.ts +++ b/src/commands/install/usage.ts @@ -49,6 +49,16 @@ const printSkillEditorUsage = (target: 'codex' | 'antigravity'): void => { console.log(''); }; +const printCursorUsage = (): void => { + console.log(chalk.bold(' Cursor:')); + console.log(chalk.gray(' Rules (16 agents) ~/.cursor/rules/ or ./.cursor/rules/')); + console.log(chalk.gray(' Commands (4) ~/.cursor/commands/ or ./.cursor/commands/')); + console.log(chalk.gray(' Skills (21) ~/.cursor/skills/ or ./.cursor/skills/')); + console.log(chalk.gray(' Architecture (11) ~/.cursor/architecture/ or ./.cursor/architecture/')); + console.log(chalk.gray(' Use @agent-name in chat or slash commands from the command palette')); + console.log(''); +}; + export const printUsage = (targets: EditorTarget[]): void => { console.log(''); console.log(chalk.cyan('════════════════════════════════════════')); @@ -65,8 +75,11 @@ export const printUsage = (targets: EditorTarget[]): void => { if (targets.includes('antigravity')) { printSkillEditorUsage('antigravity'); } + if (targets.includes('cursor')) { + printCursorUsage(); + } - const rulesFileTargets = targets.filter((t) => t === 'cursor' || t === 'windsurf'); + const rulesFileTargets = targets.filter((t) => t === 'windsurf'); if (rulesFileTargets.length > 0) { console.log(chalk.bold(' Rules-file Editors:')); for (const target of rulesFileTargets) { diff --git a/src/commands/install/write-if-changed.ts b/src/commands/install/write-if-changed.ts new file mode 100644 index 0000000..5ad0419 --- /dev/null +++ b/src/commands/install/write-if-changed.ts @@ -0,0 +1,15 @@ +import fs from 'fs'; +import type { FileResult } from '../../types.js'; + +/** Write content only if it differs; report created/updated/exists. */ +export const writeIfChanged = (targetFile: string, content: string, name: string): FileResult => { + if (fs.existsSync(targetFile)) { + if (fs.readFileSync(targetFile, 'utf-8') === content) { + return { status: 'exists', name }; + } + fs.writeFileSync(targetFile, content); + return { status: 'updated', name }; + } + fs.writeFileSync(targetFile, content); + return { status: 'created', name }; +}; diff --git a/src/commands/list.ts b/src/commands/list.ts index 20e5c37..8b83f5e 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -3,6 +3,8 @@ import fs from 'fs'; import path from 'path'; import type { CommandOptions, ItemType, Scope } from '../types.js'; import { isDisabled } from '../utils/config.js'; +import { cleanItemDisplayName, listCursorRuleItems } from '../utils/editor-items.js'; +import { DISABLED_SUFFIX } from '../utils/editor-constants.js'; import { listItems, listSkillsNested, @@ -12,6 +14,10 @@ import { getClaudeDir, getCodexDir, getAntigravityDir, + getCursorDir, + getEditorAgentsDir, + getEditorCommandsDir, + getEditorSkillsDir, } from '../utils/symlink.js'; const printHeader = (): void => { @@ -34,8 +40,8 @@ const printItems = ( for (const item of items) { const icon = item.isSymlink ? chalk.blue('→') : chalk.green('●'); - const cleanName = item.name.replace('.md', '').replace('.disabled', ''); - const isFileDisabled = item.name.endsWith('.disabled'); + const cleanName = cleanItemDisplayName(item.name); + const isFileDisabled = item.name.endsWith(DISABLED_SUFFIX); const isConfigDisabled = isDisabled(type, cleanName); const itemDisabled = isFileDisabled || isConfigDisabled; @@ -136,6 +142,37 @@ const listAntigravityScope = (scope: Scope): void => { console.log(''); }; +const listCursorScope = (scope: Scope): void => { + const cursorDir = getCursorDir(scope); + const label = + scope === 'global' ? 'Global (~/.cursor/)' : `Project (${process.cwd()}/.cursor/)`; + + console.log(chalk.cyan(`>>> ${label}`)); + console.log(''); + + if (!fs.existsSync(cursorDir)) { + console.log(chalk.gray(' Not installed')); + console.log(''); + return; + } + + console.log(chalk.yellow(' Rules (agents):')); + printItems(listCursorRuleItems(getEditorAgentsDir('cursor', scope)), 'agents', 'agents'); + console.log(''); + + console.log(chalk.yellow(' Commands:')); + printItems(listItems(getEditorCommandsDir('cursor', scope)), 'commands', 'commands'); + console.log(''); + + console.log(chalk.yellow(' Skills:')); + printItems(listSkillsNested(getEditorSkillsDir('cursor', scope)), 'skills', 'skills'); + console.log(''); + + console.log(chalk.yellow(' Architecture:')); + printPlainItems(listItems(path.join(cursorDir, 'architecture')), 'architecture docs'); + console.log(''); +}; + export const listCommand = async (options: CommandOptions): Promise => { printHeader(); @@ -163,6 +200,18 @@ export const listCommand = async (options: CommandOptions): Promise => { return; } + if (options.target === 'cursor') { + if (options.global) { + listCursorScope('global'); + } else if (options.project) { + listCursorScope('project'); + } else { + listCursorScope('global'); + listCursorScope('project'); + } + return; + } + if (options.global) { listScope('global'); } else if (options.project) { diff --git a/src/commands/postinstall.ts b/src/commands/postinstall.ts index 09c3dcc..3561691 100644 --- a/src/commands/postinstall.ts +++ b/src/commands/postinstall.ts @@ -16,7 +16,8 @@ export const postinstallCommand = async (): Promise => { console.log(chalk.gray(' moicle install --all # Install to both')); console.log(chalk.gray(' moicle install --target codex --global # Codex skills → ~/.codex/')); console.log(chalk.gray(' moicle install --target antigravity --global # Antigravity skills → ~/.gemini/')); - console.log(chalk.gray(' moicle install --target cursor # Cursor rules → ~/.cursor/')); + console.log(chalk.gray(' moicle install --target cursor --global # Cursor → ~/.cursor/rules, skills, commands')); + console.log(chalk.gray(' moicle install --target cursor --project # Cursor → ./.cursor/')); console.log(chalk.gray(' moicle install --target windsurf # Windsurf rules')); console.log(''); console.log('Other commands:'); diff --git a/src/commands/status.ts b/src/commands/status.ts index 0438618..bece88d 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,6 +3,8 @@ import fs from 'fs'; import path from 'path'; import type { CommandOptions, ItemType, Scope } from '../types.js'; import { getTargets, isDisabled } from '../utils/config.js'; +import { cleanItemDisplayName, listCursorRuleItems } from '../utils/editor-items.js'; +import { DISABLED_SUFFIX } from '../utils/editor-constants.js'; import { EDITOR_CONFIGS, getAgentsDir, @@ -11,8 +13,13 @@ import { getClaudeDir, getCodexDir, getAntigravityDir, + getCursorDir, getEditorDir, + getEditorAgentsDir, + getEditorCommandsDir, + getEditorSkillsDir, listItems, + listSkillsNested, } from '../utils/symlink.js'; const printHeader = (): void => { @@ -29,8 +36,8 @@ interface ItemStats { } const getItemStatus = (item: ReturnType[0], type: ItemType): boolean => { - const cleanName = item.name.replace('.md', '').replace('.disabled', ''); - const isFileDisabled = item.name.endsWith('.disabled'); + const cleanName = cleanItemDisplayName(item.name); + const isFileDisabled = item.name.endsWith(DISABLED_SUFFIX); const isConfigDisabled = isDisabled(type, cleanName); return isFileDisabled || isConfigDisabled; }; @@ -49,7 +56,7 @@ const printItems = ( let disabled = 0; for (const item of items) { - const cleanName = item.name.replace('.md', '').replace('.disabled', ''); + const cleanName = cleanItemDisplayName(item.name); const isItemDisabled = getItemStatus(item, type); if (isItemDisabled) { @@ -179,6 +186,49 @@ const showTargetsStatus = (): void => { console.log(''); }; +const showCursorStatus = (scope: Scope = 'global'): void => { + const cursorDir = getCursorDir(scope); + const label = + scope === 'global' ? 'Global (~/.cursor/)' : `Project (${process.cwd()}/.cursor/)`; + + console.log(chalk.cyan(`>>> ${label}`)); + console.log(''); + + if (!fs.existsSync(cursorDir)) { + console.log(chalk.gray(' Not installed')); + console.log(''); + return; + } + + let totalEnabled = 0; + let totalDisabled = 0; + + console.log(chalk.yellow(' Rules (agents):')); + const agentItems = listCursorRuleItems(getEditorAgentsDir('cursor', scope)); + const agentStats = printItems(agentItems, 'agents', 'agents'); + totalEnabled += agentStats.enabled; + totalDisabled += agentStats.disabled; + console.log(''); + + console.log(chalk.yellow(' Commands:')); + const cmdItems = listItems(getEditorCommandsDir('cursor', scope)); + const cmdStats = printItems(cmdItems, 'commands', 'commands'); + totalEnabled += cmdStats.enabled; + totalDisabled += cmdStats.disabled; + console.log(''); + + console.log(chalk.yellow(' Skills:')); + const skillItems = listSkillsNested(getEditorSkillsDir('cursor', scope)); + const skillStats = printItems(skillItems, 'skills', 'skills'); + totalEnabled += skillStats.enabled; + totalDisabled += skillStats.disabled; + console.log(''); + + console.log(chalk.gray(' ────────────────────────────────────')); + console.log(` ${chalk.green('Enabled:')} ${totalEnabled} ${chalk.red('Disabled:')} ${totalDisabled}`); + console.log(''); +}; + export const statusCommand = async (options: CommandOptions): Promise => { printHeader(); @@ -208,6 +258,18 @@ export const statusCommand = async (options: CommandOptions): Promise => { return; } + if (options.target === 'cursor') { + if (options.global) { + showCursorStatus('global'); + } else if (options.project) { + showCursorStatus('project'); + } else { + showCursorStatus('global'); + showCursorStatus('project'); + } + return; + } + if (options.global) { showStatus('global'); } else if (options.project) { diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 2e52cac..9537ef3 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -18,6 +18,7 @@ import { getEditorConfig, } from '../utils/symlink.js'; import { getTargets, removeTarget } from '../utils/config.js'; +import { CURSOR_RULE_EXT, DISABLED_SUFFIX, MARKDOWN_EXT } from '../utils/editor-constants.js'; const printHeader = (): void => { console.log(''); @@ -181,6 +182,94 @@ const getAntigravityManagedNames = (): { architecture: string[]; skills: string[ return { architecture, skills }; }; +const getCursorManagedNames = (): { + architecture: string[]; + rules: string[]; + commands: string[]; + skills: string[]; +} => { + const architecture: string[] = []; + const rules: string[] = []; + const commands: string[] = []; + const skills: string[] = []; + + const archDir = path.join(ASSETS_DIR, 'architecture'); + if (fs.existsSync(archDir)) { + fs.readdirSync(archDir).forEach((name) => architecture.push(name)); + } + + const skillsDir = path.join(ASSETS_DIR, 'skills'); + if (fs.existsSync(skillsDir)) { + listSkillsNested(skillsDir).forEach((s) => skills.push(s.name)); + } + + const commandsDir = path.join(ASSETS_DIR, 'commands'); + if (fs.existsSync(commandsDir)) { + fs.readdirSync(commandsDir).forEach((name) => commands.push(name.replace(/\.md$/, ''))); + } + + for (const dirName of ['developers', 'utilities']) { + const agentsDir = path.join(ASSETS_DIR, 'agents', dirName); + if (fs.existsSync(agentsDir)) { + fs.readdirSync(agentsDir).forEach((name) => rules.push(name.replace(/\.md$/, ''))); + } + } + + return { architecture, rules, commands, skills }; +}; + +const uninstallCursorScope = async (scope: Scope): Promise => { + const label = scope === 'global' ? 'Global' : 'Project'; + const targetDir = getEditorDir('cursor', scope); + const spinner = ora(`Uninstalling Cursor assets from ${label.toLowerCase()} scope...`).start(); + const managed = getCursorManagedNames(); + + let removed = 0; + + const archDir = path.join(targetDir, 'architecture'); + for (const name of managed.architecture) { + for (const candidate of [name, `${name}${DISABLED_SUFFIX}`]) { + const result = removeItem(path.join(archDir, candidate)); + if (result.status === 'removed') { + removed++; + } + } + } + + const rulesDir = path.join(targetDir, 'rules'); + for (const name of managed.rules) { + for (const suffix of ['', DISABLED_SUFFIX]) { + const result = removeItem(path.join(rulesDir, `${name}${CURSOR_RULE_EXT}${suffix}`)); + if (result.status === 'removed') { + removed++; + } + } + } + + const commandsDir = path.join(targetDir, 'commands'); + for (const name of managed.commands) { + for (const suffix of ['', DISABLED_SUFFIX]) { + const result = removeItem(path.join(commandsDir, `${name}${MARKDOWN_EXT}${suffix}`)); + if (result.status === 'removed') { + removed++; + } + } + } + + const skillsDir = path.join(targetDir, 'skills'); + for (const name of managed.skills) { + for (const suffix of ['', DISABLED_SUFFIX]) { + const result = removeItem(path.join(skillsDir, `${name}${suffix}`)); + if (result.status === 'removed') { + removed++; + } + } + } + + spinner.succeed(`Removed ${removed} Cursor items from ${label.toLowerCase()} scope`); + console.log(chalk.green(`✓ ${label} Cursor uninstall complete!`)); +}; + const uninstallAntigravityScope = async (scope: Scope): Promise => { const label = scope === 'global' ? 'Global' : 'Project'; const targetDir = getAntigravityDir(scope); @@ -234,7 +323,7 @@ const uninstallForOtherEditor = async (target: EditorTarget): Promise => { const showTargetMenu = async (): Promise => { const installedTargets = getTargets(); - const availableTargets = installedTargets.length > 0 ? installedTargets : (['claude', 'codex', 'antigravity'] as EditorTarget[]); + const availableTargets = installedTargets.length > 0 ? installedTargets : (['claude', 'codex', 'cursor', 'antigravity'] as EditorTarget[]); const { target } = await inquirer.prompt([ { @@ -252,10 +341,16 @@ const showTargetMenu = async (): Promise => { }; const showInteractiveMenu = async ( - target: 'claude' | 'codex' | 'antigravity' + target: 'claude' | 'codex' | 'cursor' | 'antigravity' ): Promise<'global' | 'project' | 'all'> => { - const globalPath = target === 'claude' ? '~/.claude/' : target === 'codex' ? '~/.codex/' : '~/.gemini/'; - const projectPath = target === 'claude' ? './.claude/' : target === 'codex' ? './.codex/' : './.gemini/'; + const pathByTarget: Record = { + claude: { global: '~/.claude/', project: './.claude/' }, + codex: { global: '~/.codex/', project: './.codex/' }, + cursor: { global: '~/.cursor/', project: './.cursor/' }, + antigravity: { global: '~/.gemini/', project: './.gemini/' }, + }; + const globalPath = pathByTarget[target].global; + const projectPath = pathByTarget[target].project; const { uninstallType } = await inquirer.prompt([ { @@ -293,7 +388,7 @@ export const uninstallCommand = async (options: CommandOptions): Promise = const targets = options.target ? [options.target] : [await showTargetMenu()]; for (const target of targets) { - if (target === 'claude' || target === 'codex' || target === 'antigravity') { + if (target === 'claude' || target === 'codex' || target === 'cursor' || target === 'antigravity') { let uninstallType: 'global' | 'project' | 'all'; if (options.global) { @@ -312,6 +407,8 @@ export const uninstallCommand = async (options: CommandOptions): Promise = await uninstallScope('global'); } else if (target === 'codex') { await uninstallCodexScope('global'); + } else if (target === 'cursor') { + await uninstallCursorScope('global'); } else { await uninstallAntigravityScope('global'); } @@ -321,6 +418,8 @@ export const uninstallCommand = async (options: CommandOptions): Promise = await uninstallScope('project'); } else if (target === 'codex') { await uninstallCodexScope('project'); + } else if (target === 'cursor') { + await uninstallCursorScope('project'); } else { await uninstallAntigravityScope('project'); } @@ -332,6 +431,9 @@ export const uninstallCommand = async (options: CommandOptions): Promise = } else if (target === 'codex') { await uninstallCodexScope('global'); await uninstallCodexScope('project'); + } else if (target === 'cursor') { + await uninstallCursorScope('global'); + await uninstallCursorScope('project'); } else { await uninstallAntigravityScope('global'); await uninstallAntigravityScope('project'); diff --git a/src/utils/editor-constants.ts b/src/utils/editor-constants.ts new file mode 100644 index 0000000..30cdaf7 --- /dev/null +++ b/src/utils/editor-constants.ts @@ -0,0 +1,4 @@ +export const DISABLED_SUFFIX = '.disabled'; +export const CURSOR_RULE_EXT = '.mdc'; +export const MARKDOWN_EXT = '.md'; +export const DESCRIPTION_MAX_LENGTH = 500; diff --git a/src/utils/editor-items.ts b/src/utils/editor-items.ts new file mode 100644 index 0000000..901c606 --- /dev/null +++ b/src/utils/editor-items.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import fs from 'fs'; +import type { CommandOptions, EditorTarget, ItemType, ListItem, Scope } from '../types.js'; +import { CURSOR_RULE_EXT, DISABLED_SUFFIX, MARKDOWN_EXT } from './editor-constants.js'; +import { + getEditorAgentsDir, + getEditorCommandsDir, + getEditorSkillsDir, + listItems, +} from './symlink.js'; + +export const resolveEditorTarget = (options: CommandOptions): EditorTarget => + options.target ?? 'claude'; + +export const cleanItemDisplayName = (fileName: string): string => { + if (fileName.endsWith(`${CURSOR_RULE_EXT}${DISABLED_SUFFIX}`)) { + return fileName.slice(0, -(CURSOR_RULE_EXT.length + DISABLED_SUFFIX.length)); + } + if (fileName.endsWith(`${MARKDOWN_EXT}${DISABLED_SUFFIX}`)) { + return fileName.slice(0, -(MARKDOWN_EXT.length + DISABLED_SUFFIX.length)); + } + if (fileName.endsWith(CURSOR_RULE_EXT)) { + return fileName.slice(0, -CURSOR_RULE_EXT.length); + } + if (fileName.endsWith(MARKDOWN_EXT)) { + return fileName.slice(0, -MARKDOWN_EXT.length); + } + if (fileName.endsWith(DISABLED_SUFFIX)) { + return fileName.slice(0, -DISABLED_SUFFIX.length); + } + return fileName; +}; + +export const getItemDir = (type: ItemType, target: EditorTarget, scope: Scope): string => { + switch (type) { + case 'agents': + return getEditorAgentsDir(target, scope); + case 'commands': + return getEditorCommandsDir(target, scope); + case 'skills': + return getEditorSkillsDir(target, scope); + } +}; + +export const getAgentEnabledPath = (target: EditorTarget, dir: string, name: string): string => + path.join( + dir, + target === 'cursor' ? `${name}${CURSOR_RULE_EXT}` : `${name}${MARKDOWN_EXT}` + ); + +export const getAgentDisabledPath = (target: EditorTarget, dir: string, name: string): string => + path.join( + dir, + target === 'cursor' + ? `${name}${CURSOR_RULE_EXT}${DISABLED_SUFFIX}` + : `${name}${MARKDOWN_EXT}${DISABLED_SUFFIX}` + ); + +export const getCommandEnabledPath = (dir: string, name: string): string => + path.join(dir, `${name}${MARKDOWN_EXT}`); + +export const getCommandDisabledPath = (dir: string, name: string): string => + path.join(dir, `${name}${MARKDOWN_EXT}${DISABLED_SUFFIX}`); + +export const listCursorRuleItems = (rulesDir: string): ListItem[] => + listItems(rulesDir).filter( + (item) => + item.name.endsWith(CURSOR_RULE_EXT) || + item.name.endsWith(`${CURSOR_RULE_EXT}${DISABLED_SUFFIX}`) + ); + +export const inferItemType = ( + itemName: string, + target: EditorTarget, + scope: Scope +): ItemType => { + const cleanName = itemName.replace('@', '').replace('/', ''); + if (itemName.startsWith('/')) { + return 'commands'; + } + if (itemName.startsWith('@')) { + return 'agents'; + } + const skillsDir = getEditorSkillsDir(target, scope); + if ( + fs.existsSync(path.join(skillsDir, cleanName)) || + fs.existsSync(path.join(skillsDir, `${cleanName}${DISABLED_SUFFIX}`)) + ) { + return 'skills'; + } + return 'agents'; +}; diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts index 09a27ce..26a10bc 100644 --- a/src/utils/symlink.ts +++ b/src/utils/symlink.ts @@ -37,12 +37,11 @@ export const EDITOR_CONFIGS: Record = { name: 'Cursor', globalDir: path.join(os.homedir(), '.cursor'), agentsDir: 'rules', - commandsDir: 'rules', - skillsDir: 'rules', - rulesFile: 'AGENTS.md', + commandsDir: 'commands', + skillsDir: 'skills', supportsAgents: true, - supportsCommands: false, - supportsSkills: false, + supportsCommands: true, + supportsSkills: true, }, windsurf: { name: 'Windsurf', @@ -121,6 +120,10 @@ export const getCodexDir = (scope: Scope = 'global'): string => { return getEditorDir('codex', scope); }; +export const getCursorDir = (scope: Scope = 'global'): string => { + return getEditorDir('cursor', scope); +}; + export const getAgentsDir = (scope: Scope = 'global'): string => path.join(getClaudeDir(scope), 'agents');