From 3411d35806ee9888b036308b1c0a200590df3fb4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 24 Nov 2025 01:38:36 -0500 Subject: [PATCH] fix: prevent Claude marketplaces from leaking into project config Signed-off-by: Yordis Prieto --- docs/explanation/architecture.md | 505 +++--------------- docs/how-to/create-marketplace.md | 2 +- docs/how-to/debug-plugins.md | 27 +- docs/how-to/installation.md | 2 +- docs/reference/cli-commands.md | 8 +- src/commands/list.ts | 2 +- src/commands/plugin-disable.ts | 26 +- src/commands/plugin-enable.ts | 44 +- src/commands/plugin-install.ts | 18 +- src/commands/plugin-uninstall.ts | 46 +- src/commands/sync.ts | 15 +- src/config/loader.ts | 2 +- src/constants.ts | 1 + src/helpers/marketplace.ts | 4 +- src/helpers/sync-strategy.ts | 31 +- .../claude-code-auto-discovery.test.ts | 22 +- tests/commands/plugin-install.test.ts | 8 +- tests/commands/plugin-uninstall.test.ts | 8 +- tests/commands/plugin-update.test.ts | 10 +- tests/commands/sync-git.test.ts | 22 +- tests/commands/sync-marketplace-json.test.ts | 24 +- tests/commands/sync-rules.test.ts | 20 +- tests/commands/sync.test.ts | 92 ++-- 23 files changed, 319 insertions(+), 620 deletions(-) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 7914910..78b7bf7 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -1,473 +1,140 @@ # Architecture Overview -This document explains how aipm is designed and how its components work together. - ---- +aipm follows a **layered architecture** with clear separation of concerns. ## System Design -aipm follows a **layered architecture** with clear separation of concerns: - -``` -??????????????????????????????????????????? -? CLI Layer (cli.ts) ? ? User interaction -??????????????????????????????????????????? -? Commands Layer (commands/) ? ? Business logic -??????????????????????????????????????????? -? Config Layer (config/) ? ? Configuration management -??????????????????????????????????????????? -? Utils Layer (utils/) ? ? Shared utilities -??????????????????????????????????????????? -? External Systems (Git, FS) ? ? Infrastructure -??????????????????????????????????????????? -``` - ---- - -## Core Components - -### 1. CLI Layer - -**Responsibility**: Parse arguments and route to commands - -**Key file**: `src/cli.ts` - -**What it does**: - -- Parses command-line arguments using Node's `parseArgs` -- Routes to appropriate command handler -- Handles `--help`, `--version`, global flags -- Catches and displays errors - -**Example flow**: - -``` -$ aipm plugin install my-plugin@local - ? -cli.ts parses args ? { command: "plugin", subcommand: "install", pluginId: "my-plugin@local" } - ? -Calls: pluginInstall({ pluginId: "my-plugin@local", ... }) -``` - ---- - -### 2. Commands Layer - -**Responsibility**: Implement business logic for each command - -**Location**: `src/commands/` - -**Key commands**: - -- `init.ts` - Initialize project configuration -- `sync.ts` - Sync plugins from marketplaces -- `marketplace-*.ts` - Marketplace management -- `plugin-*.ts` - Plugin management - -**Common pattern**: - -```typescript -export async function commandName(options: Options) { - // 1. Validate options (using Zod) - const validated = OptionsSchema.parse(options); - - // 2. Load config - const config = await loadPluginsConfig(); - - // 3. Perform operation - // ... business logic ... - - // 4. Save config (if needed) - await writeConfig(config); - - // 5. Provide feedback - console.log('? Success!'); -} -``` - ---- - -### 3. Config Layer - -**Responsibility**: Load, merge, and validate configuration - -**Location**: `src/config/` - -**Key files**: - -- `loader.ts` - Three-way config merge -- `schema.ts` - TypeScript types -- `validation.ts` - Zod schemas - -**Three-way merge**: - -```typescript -// Priority: local > project > global -const config = mergeConfigs( - globalConfig, // ~/.config/aipm/config.json (or %APPDATA%\aipm\config.json on Windows) - projectConfig, // .aipm/config.json - localConfig, // .aipm/config.local.json (gitignored) -); +```mermaid +graph TD + A[CLI Layer
cli.ts] --> B[Commands Layer
commands/] + B --> C[Config Layer
config/] + B --> D[Helpers Layer
helpers/] + C --> E[External Systems
Git, FS] + D --> E ``` -See [Configuration System](./config-system.md) for details. - ---- - -### 4. Utils Layer - -**Responsibility**: Shared utilities and helpers - -**Location**: `src/utils/` - -**Key modules**: - -- `git.ts` - Git operations (clone, pull, cache) -- `fs.ts` - File system operations -- `marketplace.ts` - Marketplace resolution -- `plugin.ts` - Plugin operations -- `sync-strategy.ts` - Sync algorithm -- `paths.ts` - Path resolution -- `github.ts` - GitHub URL handling -- `io.ts` - User input/output +| Layer | Responsibility | +| -------- | ----------------------------------- | +| CLI | Parse arguments, route to commands | +| Commands | Business logic for each command | +| Config | Load, merge, validate configuration | +| Helpers | Shared utilities (git, fs, sync) | --- ## Data Flow -### Installing a Plugin - -``` -User: aipm plugin install my-plugin@local - ? -1. CLI parses command - ? -2. pluginInstall() called - ? -3. Load config (three-way merge) - ? -4. Parse pluginId ? { name: "my-plugin", marketplace: "local" } - ? -5. Check if plugin exists in marketplace - ? -6. Enable plugin in config - ? -7. Sync plugin files (via syncCommand) - ? -8. Resolve marketplace path (git clone/pull if needed) - ? -9. Copy plugin files to .cursor/marketplace/ - ? -10. Save config - ? -11. Show success message -``` +### Plugin Installation -### Syncing Plugins +```mermaid +sequenceDiagram + participant User + participant CLI + participant Config + participant Marketplace + participant FS + User->>CLI: aipm plugin install my-plugin@local + CLI->>Config: Load config (three-way merge) + CLI->>Marketplace: Resolve marketplace path + Marketplace-->>CLI: Clone/pull if git, or use local path + CLI->>FS: Copy files to .cursor/{type}/aipm/ + CLI->>Config: Save updated config + CLI->>User: Success message ``` -User: aipm sync - ? -1. Load config - ? -2. For each enabled plugin: - a. Resolve marketplace path - - Git: Clone to ~/.cache/aipm/ (or pull if exists) - - Local: Use directory directly - - URL: Fetch marketplace.json - b. Find plugin source in marketplace - c. Copy plugin files to .cursor/marketplace/{marketplace}/{plugin}/ - ? -3. Show summary -``` - ---- - -## Configuration Management -### File Locations +### Sync Flow -``` -User's machine: -└ ~/.config/aipm/ (config) -└ ~/.cache/aipm/ (cache) - ├── config.json # Global config - └── cache/ - └── {marketplace}/ # Git marketplace clones - -Project: -└ .aipm/ - ├── config.json # Project config (committed) - └── config.local.json # Local overrides (gitignored) - -└ .cursor/ # Synced plugin content - └── marketplace/ - └── {marketplace}/ - └── {plugin}/ # Plugin files -``` +```mermaid +sequenceDiagram + participant User + participant CLI + participant Config + participant FS -### Config Priority - -``` -Local (.aipm/config.local.json) - ? overrides -Project (.aipm/config.json) - ? overrides -Global (~/.config/aipm/config.json) + User->>CLI: aipm sync + CLI->>Config: Load enabled plugins + loop Each enabled plugin + CLI->>FS: Copy to .cursor/{type}/aipm/{marketplace}/{plugin}/ + end + CLI->>User: Summary ``` -See [Configuration System](./config-system.md) for details. - --- -## Marketplace Resolution - -Different marketplace types are resolved differently: - -### Local Directory +## File Locations ``` -marketplace: { source: "directory", path: "./my-plugins" } - ? -Resolve to absolute path - ? -Scan directory for plugins -``` +~/.config/aipm/config.json # Global config +~/.cache/aipm/{marketplace}/ # Git marketplace clones -### Git Repository +.aipm/ +├── config.json # Project config (committed) +└── config.local.json # Local overrides (gitignored) -``` -marketplace: { source: "git", url: "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/user/plugins.git" } - ? -Clone to ~/.cache/aipm/{marketplace-name}/ - (or pull if already exists) - ? -Read marketplace.json from clone - ? -Scan for plugins +.cursor/ +├── commands/aipm/{marketplace}/{plugin}/ +├── rules/aipm/{marketplace}/{plugin}/ +├── agents/aipm/{marketplace}/{plugin}/ +├── skills/aipm/{marketplace}/{plugin}/ +└── hooks/aipm/{marketplace}/{plugin}/ ``` -### Remote URL - -``` -marketplace: { source: "url", url: "https://cdn.com/marketplace.json" } - ? -Fetch marketplace.json - ? -Cache to ~/.cache/aipm/{marketplace-name}/ - ? -Parse plugin list -``` - -See [Marketplace Types](./marketplace-types.md) for details. - --- -## Plugin Sync Strategy - -aipm uses a **copy-based sync strategy**: - -1. **Resolve source**: Find where the plugin lives (marketplace path) -2. **Copy files**: Copy entire plugin directory to `.cursor/marketplace/` -3. **Preserve structure**: Maintain plugin directory structure +## Config Priority -**Why copy instead of symlink?** - -- Cross-platform compatibility (Windows support) -- No broken links if source moves -- Isolated per-project (different projects can have different versions) - -See [Git Cache Strategy](./git-cache.md) for git-specific details. - ---- - -## Error Handling - -aipm uses a **fail-fast** approach: - -```typescript -// Validate early -const options = OptionsSchema.parse(input); // Throws if invalid - -// Check preconditions -if (!configExists) { - throw new Error("Run 'aipm init' first"); -} - -// Atomic operations where possible -// Either complete fully or rollback -``` - -**Error bubbling**: - -``` -Utils layer throws - ? -Commands layer catches, adds context - ? -CLI layer catches, formats for user - ? -process.exit(1) +```mermaid +graph TD + A[Local: .aipm/config.local.json] -->|overrides| B[Project: .aipm/config.json] + B -->|overrides| C[Global: ~/.config/aipm/config.json] ``` --- -## Testing Strategy - -aipm uses **test doubles** for external dependencies: +## Marketplace Resolution -```typescript -// Mock file system -const mockFS = { - readFile: async () => 'mock content', - writeFile: async () => {}, -}; +```mermaid +graph LR + subgraph Sources + A[Local Directory] + B[Git Repository] + C[Remote URL] + end -// Mock IO -const mockIO = new MockIO(); -mockIO.confirmResponses = [true]; // Auto-confirm prompts + A --> D[Use path directly] + B --> E[Clone to ~/.cache/aipm/] + C --> F[Fetch & cache marketplace.json] -// Test commands in isolation -await command({ io: mockIO, fs: mockFS }); + D --> G[Scan for plugins] + E --> G + F --> G ``` -See [Testing Guide](../../TESTING.md) for details. - --- ## Design Principles -### 1. **Explicit over implicit** - -- Config files are explicit (not hidden magic) -- All paths are absolute when resolved -- Clear error messages - -### 2. **Composable** - -- Small, focused functions -- Utilities can be used independently -- Commands build on utils - -### 3. **Type-safe** - -- TypeScript strict mode -- Zod runtime validation -- Catch errors at boundaries - -### 4. **Testable** - -- Dependency injection (IO, FS) -- Pure functions where possible -- Integration tests for workflows - -### 5. **User-friendly** - -- `--dry-run` for safety -- Helpful error messages -- Progress feedback +1. **Explicit over implicit** - Config files are explicit, paths are absolute +2. **Composable** - Small functions, utilities work independently +3. **Type-safe** - TypeScript strict mode, Zod validation +4. **Testable** - Dependency injection, pure functions +5. **User-friendly** - `--dry-run`, helpful errors --- ## Extension Points -aipm is designed to be extended: - -### Adding New Commands - -1. Create `src/commands/my-command.ts` -2. Define options schema in `src/config/validation.ts` -3. Register in `src/cli.ts` -4. Add tests in `tests/commands/my-command.test.ts` - -### Adding New Marketplace Types - -1. Add type to `MarketplaceSource` in `src/config/schema.ts` -2. Update `resolveMarketplacePath()` in `src/utils/git.ts` -3. Add validation in `src/config/validation.ts` -4. Add tests - -### Adding New Sync Strategies - -1. Implement strategy in `src/utils/sync-strategy.ts` -2. Add configuration option -3. Update `syncCommand()` to use new strategy - ---- - -## Performance Considerations - -### Git Operations - -- **Shallow clones**: Uses `--depth 1` for faster clones -- **Caching**: Git repos cached in `~/.cache/aipm/` (follows XDG Base Directory spec) -- **Lazy updates**: Only pulls when `marketplace update` called - -### File Operations - -- **Batch copies**: Uses efficient file copying -- **Concurrent ops**: Multiple plugins synced in parallel (future) -- **Skip unchanged**: Only copies if source changed (future) - -### Configuration - -- **Lazy loading**: Config only loaded when needed -- **Minimal parsing**: Uses streaming JSON where possible -- **No watchers**: Explicit sync, no file watching overhead - ---- - -## Security Considerations - -### Git Clones - -- **Force reset**: Always reset to remote state (prevent tampering) -- **Clean untracked**: Remove untracked files on update -- **No auto-exec**: Never execute code from plugins during sync - -### File Paths - -- **Path traversal protection**: Validate all paths -- **Absolute paths**: Resolve to absolute to prevent confusion -- **No hidden writes**: All file operations logged - -### Configuration - -- **Local overrides**: Secrets go in `.aipm/config.local.json` (gitignored) -- **Validation**: All configs validated with Zod schemas -- **No eval**: No dynamic code execution - -See [Security Policy](../../SECURITY.md) for more. - ---- - -## Future Architecture - -Potential improvements: - -### Plugin API - -- Plugins could define TypeScript APIs -- Version compatibility checking -- Dependency resolution between plugins - -### Remote Registry - -- Central plugin registry (like npm) -- Publishing workflow -- Automatic updates - -### Performance - -- Parallel sync operations -- Incremental updates (only changed files) -- Content-addressable storage (like git) +| Extension | Steps | +| -------------------- | ------------------------------------------------------------- | +| New command | Create `src/commands/my-command.ts`, register in `cli.ts` | +| New marketplace type | Add to `MarketplaceSource`, update `resolveMarketplacePath()` | +| New sync strategy | Implement in `sync-strategy.ts`, add config option | --- ## Related -- [Configuration System](./config-system.md) - Config details -- [Git Cache Strategy](./git-cache.md) - Git operations -- [Plugin Lifecycle](./plugin-lifecycle.md) - Plugin states -- [Design Decisions](./design-decisions.md) - Why we made these choices +- [Configuration System](./config-system.md) +- [Git Cache Strategy](./git-cache.md) +- [Plugin Lifecycle](./plugin-lifecycle.md) diff --git a/docs/how-to/create-marketplace.md b/docs/how-to/create-marketplace.md index db88c92..9f60eca 100644 --- a/docs/how-to/create-marketplace.md +++ b/docs/how-to/create-marketplace.md @@ -238,7 +238,7 @@ See [Working with Monorepos](./monorepo-marketplace.md) for details. Check access: ```bash -git clone +git clone {repository-url} ``` ### "Permission denied" diff --git a/docs/how-to/debug-plugins.md b/docs/how-to/debug-plugins.md index b88a72e..7487830 100644 --- a/docs/how-to/debug-plugins.md +++ b/docs/how-to/debug-plugins.md @@ -50,7 +50,7 @@ aipm sync ```bash # For git marketplaces, clone and inspect - git clone /tmp/check-marketplace + git clone {marketplace-url} /tmp/check-marketplace cat /tmp/check-marketplace/marketplace.json ``` @@ -89,8 +89,9 @@ aipm sync 4. **Check plugin files**: ```bash - # Files should exist in .cursor/marketplace/ - ls -la .cursor/marketplace/my-marketplace/my-plugin/ + # Files should exist in .cursor/{type}/aipm/ + ls -la .cursor/commands/aipm/my-marketplace/my-plugin/ + ls -la .cursor/rules/aipm/my-marketplace/my-plugin/ ``` 5. **Restart Claude Code** (if using with Claude Code) @@ -106,7 +107,7 @@ aipm sync 1. **Test git access**: ```bash - git clone /tmp/test-clone + git clone {repository-url} /tmp/test-clone ``` 2. **Check SSH keys** (for SSH URLs): @@ -125,7 +126,7 @@ aipm sync 4. **Check branch exists**: ```bash - git ls-remote + git ls-remote {repository-url} ``` --- @@ -189,7 +190,7 @@ aipm sync 3. **Check git repository is accessible**: ```bash - git ls-remote + git ls-remote {repository-url} ``` --- @@ -218,10 +219,10 @@ aipm sync 3. **Don't use sudo**: ```bash - # ? Don't do this + # BAD - Don't do this sudo aipm install ... - # ? Do this + # GOOD - Do this aipm install ... ``` @@ -278,19 +279,17 @@ dir "%LOCALAPPDATA%\aipm\cache\my-marketplace" ### Check Synced Files ```bash -# Project-level -ls -la .cursor/marketplace/ +# Project-level (plugin files are split by type) +ls -la .cursor/commands/aipm/ +ls -la .cursor/rules/aipm/ # Check specific plugin files -find .cursor/marketplace/ -name "*.md" +find .cursor/*/aipm/ -name "*.md" ``` ### Manual Validation ```bash -# Check plugin.json is valid -cat .cursor/marketplace/my-marketplace/my-plugin/.claude-plugin/plugin.json | jq . - # Check marketplace.json is valid # Linux/macOS cat ~/.cache/aipm/my-marketplace/marketplace.json | jq . diff --git a/docs/how-to/installation.md b/docs/how-to/installation.md index 68aa4db..ac372ae 100644 --- a/docs/how-to/installation.md +++ b/docs/how-to/installation.md @@ -114,7 +114,7 @@ cd aipm bun install # Run directly without building -bun run src/cli.ts +bun run src/cli.ts {command} # Or use watch mode bun run dev diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 5ab6d38..107845e 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -67,7 +67,7 @@ aipm init --global ### `sync` -Sync all enabled plugins from marketplaces to `.cursor/marketplace/`. +Sync all enabled plugins from marketplaces to `.cursor/` (commands, rules, agents, skills, hooks). **Usage**: @@ -95,7 +95,7 @@ aipm sync --dry-run 1. Loads configuration (three-way merge) 2. For each enabled plugin: - Resolves marketplace path (clones/pulls git repos) - - Copies plugin files to `.cursor/marketplace/{marketplace}/{plugin}/` + - Copies plugin files to `.cursor/{type}/aipm/{marketplace}/{plugin}/` (where type is commands, rules, agents, skills, or hooks) 3. Reports success/failures **Exit codes**: @@ -528,11 +528,11 @@ aipm list **Example output**: ``` -? Marketplaces: +Marketplaces: - team-plugins: git@github.com:acme/plugins.git - local: ./my-plugins -? Installed Plugins: +Installed Plugins: - code-reviewer@team-plugins (enabled, v1.2.0) - test-gen@local (disabled, v1.0.0) ``` diff --git a/src/commands/list.ts b/src/commands/list.ts index 7c054fb..f1c58ff 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -34,7 +34,7 @@ export async function list(options: ListOptions = {}): Promise { if (marketplaceCount > 0) { console.log('\n📦 Marketplaces:'); for (const [name, marketplace] of Object.entries(config.marketplaces)) { - const isClaudeMarketplace = name.startsWith('claude:'); + const isClaudeMarketplace = name.startsWith('claude/'); const sourceLabel = isClaudeMarketplace ? '🤖 Claude Code (auto-discovered)' : marketplace.source; console.log(` • ${name}`); diff --git a/src/commands/plugin-disable.ts b/src/commands/plugin-disable.ts index ac2b6b7..e45c69b 100644 --- a/src/commands/plugin-disable.ts +++ b/src/commands/plugin-disable.ts @@ -1,7 +1,8 @@ +import merge from 'lodash.merge'; import { z } from 'zod'; import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader'; import { FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants'; -import { saveConfig } from '../helpers/aipm-config'; +import { loadTargetConfig, saveConfig } from '../helpers/aipm-config'; import { defaultIO } from '../helpers/io'; const PluginDisableOptionsSchema = z.object({ @@ -37,23 +38,18 @@ export async function pluginDisable(options: unknown): Promise { return; } - const updatedConfig = { - ...config, - plugins: { - ...config.plugins, - [cmd.pluginId]: { - ...config.plugins[cmd.pluginId], - enabled: false, - }, - }, - }; - if (cmd.dryRun) { defaultIO.logInfo(`[DRY RUN] Would disable plugin '${cmd.pluginId}' in ${configName}`); - } else { - await saveConfig(cwd, updatedConfig, cmd.local); - defaultIO.logSuccess(`Disabled plugin '${cmd.pluginId}' in ${configName}`); + return; } + + const targetConfig = await loadTargetConfig(cwd, cmd.local); + const updatedConfig = merge({}, targetConfig, { + plugins: { [cmd.pluginId]: { enabled: false } }, + }); + + await saveConfig(cwd, updatedConfig, cmd.local); + defaultIO.logSuccess(`Disabled plugin '${cmd.pluginId}' in ${configName}`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); defaultIO.logError(`Failed to disable plugin: ${message}`); diff --git a/src/commands/plugin-enable.ts b/src/commands/plugin-enable.ts index 5c90d71..48333ed 100644 --- a/src/commands/plugin-enable.ts +++ b/src/commands/plugin-enable.ts @@ -1,7 +1,8 @@ +import merge from 'lodash.merge'; import { z } from 'zod'; import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader'; import { FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants'; -import { saveConfig } from '../helpers/aipm-config'; +import { loadTargetConfig, saveConfig } from '../helpers/aipm-config'; import { defaultIO } from '../helpers/io'; const PluginEnableOptionsSchema = z.object({ @@ -27,24 +28,6 @@ export async function pluginEnable(options: unknown): Promise { if (!config.plugins[cmd.pluginId]) { defaultIO.logInfo(`Plugin '${cmd.pluginId}' not found, adding it as enabled`); - - const updatedConfig = { - ...config, - plugins: { - ...config.plugins, - [cmd.pluginId]: { - enabled: true, - }, - }, - }; - - if (cmd.dryRun) { - defaultIO.logInfo(`[DRY RUN] Would enable plugin '${cmd.pluginId}' in ${configName}`); - } else { - await saveConfig(cwd, updatedConfig, cmd.local); - defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`); - } - return; } const plugin = config.plugins[cmd.pluginId]; @@ -54,23 +37,18 @@ export async function pluginEnable(options: unknown): Promise { return; } - const updatedConfig = { - ...config, - plugins: { - ...config.plugins, - [cmd.pluginId]: { - ...config.plugins[cmd.pluginId], - enabled: true, - }, - }, - }; - if (cmd.dryRun) { defaultIO.logInfo(`[DRY RUN] Would enable plugin '${cmd.pluginId}' in ${configName}`); - } else { - await saveConfig(cwd, updatedConfig, cmd.local); - defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`); + return; } + + const targetConfig = await loadTargetConfig(cwd, cmd.local); + const updatedConfig = merge({}, targetConfig, { + plugins: { [cmd.pluginId]: { enabled: true } }, + }); + + await saveConfig(cwd, updatedConfig, cmd.local); + defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); defaultIO.logError(`Failed to enable plugin: ${message}`); diff --git a/src/commands/plugin-install.ts b/src/commands/plugin-install.ts index 1360932..8c113e0 100644 --- a/src/commands/plugin-install.ts +++ b/src/commands/plugin-install.ts @@ -1,8 +1,9 @@ +import merge from 'lodash.merge'; import { join } from 'node:path'; import { z } from 'zod'; import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader'; import { DIR_CURSOR, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants'; -import { saveConfig } from '../helpers/aipm-config'; +import { loadTargetConfig, saveConfig } from '../helpers/aipm-config'; import { fileExists } from '../helpers/fs'; import { resolveMarketplacePath } from '../helpers/git'; import { defaultIO } from '../helpers/io'; @@ -100,22 +101,17 @@ export async function pluginInstall(options: unknown): Promise { return; } - const updatedConfig = { - ...config, - plugins: { - ...config.plugins, - [cmd.pluginId]: { - enabled: true, - }, - }, - }; - if (cmd.dryRun) { defaultIO.logInfo(`[DRY RUN] Would enable plugin '${cmd.pluginId}' in ${configName}`); defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/`); return; } + const targetConfig = await loadTargetConfig(cwd, cmd.local); + const updatedConfig = merge({}, targetConfig, { + plugins: { [cmd.pluginId]: { enabled: true } }, + }); + await saveConfig(cwd, updatedConfig, cmd.local); defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`); diff --git a/src/commands/plugin-uninstall.ts b/src/commands/plugin-uninstall.ts index a6e7316..22dbf1d 100644 --- a/src/commands/plugin-uninstall.ts +++ b/src/commands/plugin-uninstall.ts @@ -2,8 +2,8 @@ import { rm } from 'node:fs/promises'; import { join } from 'node:path'; import { z } from 'zod'; import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader'; -import { DIR_CURSOR, DIR_MARKETPLACE, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants'; -import { saveConfig } from '../helpers/aipm-config'; +import { DIR_AIPM_NAMESPACE, DIR_CURSOR, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL, PLUGIN_SUBDIRS } from '../constants'; +import { loadTargetConfig, saveConfig } from '../helpers/aipm-config'; import { fileExists } from '../helpers/fs'; import { defaultIO } from '../helpers/io'; @@ -29,26 +29,34 @@ export async function pluginUninstall(options: unknown): Promise { throw error; } const configName = cmd.local ? getConfigPath(FILE_AIPM_CONFIG_LOCAL) : getConfigPath(FILE_AIPM_CONFIG); + const targetConfig = await loadTargetConfig(cwd, cmd.local); - if (!config.plugins[cmd.pluginId]) { + if (!targetConfig.plugins[cmd.pluginId]) { + if (config.plugins[cmd.pluginId]) { + const error = new Error( + `Plugin '${cmd.pluginId}' is not in ${configName} (exists in merged config from another source)`, + ); + defaultIO.logError(error.message); + throw error; + } const error = new Error(`Plugin '${cmd.pluginId}' is not installed`); defaultIO.logError(error.message); throw error; } - const { [cmd.pluginId]: _removed, ...remainingPlugins } = config.plugins; - - const updatedConfig = { - ...config, - plugins: remainingPlugins, - }; - if (cmd.dryRun) { defaultIO.logInfo(`[DRY RUN] Would remove plugin '${cmd.pluginId}' from ${configName}`); if (cmd.removeFiles) { - defaultIO.logInfo('[DRY RUN] Would delete files from .cursor/marketplace/'); + defaultIO.logInfo('[DRY RUN] Would delete plugin files from .cursor/'); } } else { + const { [cmd.pluginId]: _removed, ...remainingPlugins } = targetConfig.plugins; + + const updatedConfig = { + ...targetConfig, + plugins: remainingPlugins, + }; + await saveConfig(cwd, updatedConfig, cmd.local); defaultIO.logSuccess(`Removed plugin '${cmd.pluginId}' from ${configName}`); @@ -56,11 +64,19 @@ export async function pluginUninstall(options: unknown): Promise { const [pluginName, marketplaceName] = cmd.pluginId.split('@'); if (pluginName && marketplaceName) { - const installedPath = join(cwd, DIR_CURSOR, DIR_MARKETPLACE, marketplaceName, pluginName); + let deletedCount = 0; + + for (const subdir of PLUGIN_SUBDIRS) { + const installedPath = join(cwd, DIR_CURSOR, subdir, DIR_AIPM_NAMESPACE, marketplaceName, pluginName); + + if (await fileExists(installedPath)) { + await rm(installedPath, { recursive: true, force: true }); + deletedCount++; + } + } - if (await fileExists(installedPath)) { - await rm(installedPath, { recursive: true, force: true }); - defaultIO.logSuccess(`Deleted plugin files from .cursor/marketplace/${marketplaceName}/${pluginName}`); + if (deletedCount > 0) { + defaultIO.logSuccess(`Deleted plugin files from ${deletedCount} location(s) in .cursor/`); } } } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 08e5f83..2e2c1e5 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -2,7 +2,7 @@ import { rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { z } from 'zod'; import { getNotInitializedMessage, loadPluginsConfig } from '../config/loader'; -import { DIR_MARKETPLACE, PLUGIN_SUBDIRS } from '../constants'; +import { DIR_AIPM_NAMESPACE, DIR_MARKETPLACE, PLUGIN_SUBDIRS } from '../constants'; import { ensureDir, fileExists } from '../helpers/fs'; import { resolveMarketplacePath } from '../helpers/git'; import { defaultIO } from '../helpers/io'; @@ -81,13 +81,14 @@ export async function sync(options: SyncOptions = {}): Promise { await rm(oldMarketplaceDir, { recursive: true, force: true }); } - // Clear and recreate target directories to remove disabled plugins + // Clear and recreate aipm subdirectories to remove disabled plugins + // Only clears .cursor//aipm/, preserving user's own files for (const subdir of targetSubdirs) { - const subdirPath = join(targetDir, subdir); - if (await fileExists(subdirPath)) { - await rm(subdirPath, { recursive: true, force: true }); + const aipmSubdirPath = join(targetDir, subdir, DIR_AIPM_NAMESPACE); + if (await fileExists(aipmSubdirPath)) { + await rm(aipmSubdirPath, { recursive: true, force: true }); } - await ensureDir(subdirPath); + await ensureDir(aipmSubdirPath); } } @@ -165,7 +166,7 @@ export async function sync(options: SyncOptions = {}): Promise { const disabledSubdirs = PLUGIN_SUBDIRS.filter((subdir) => !isSubdirEnabled(subdir, includeConfig)); for (const subdir of disabledSubdirs) { - const subdirPath = join(targetDir, subdir, marketplaceName, pluginName); + const subdirPath = join(targetDir, subdir, DIR_AIPM_NAMESPACE, marketplaceName, pluginName); if (await fileExists(subdirPath)) { await rm(subdirPath, { recursive: true, force: true }); } diff --git a/src/config/loader.ts b/src/config/loader.ts index dd62340..588e9d3 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -105,7 +105,7 @@ export async function loadPluginsConfig(baseDir: string): Promise { diff --git a/src/helpers/sync-strategy.ts b/src/helpers/sync-strategy.ts index cee0afc..f06b66c 100644 --- a/src/helpers/sync-strategy.ts +++ b/src/helpers/sync-strategy.ts @@ -1,5 +1,6 @@ import { cp, readdir, readFile, writeFile } from 'node:fs/promises'; import { basename, extname, join } from 'node:path'; +import { DIR_AIPM_NAMESPACE } from '../constants'; import { DirectoryNotFoundError, isFileNotFoundError } from '../errors'; import { applyCursorFrontmatter } from './frontmatter'; import { ensureDir, fileExists } from './fs'; @@ -14,11 +15,11 @@ export type SyncResult = { /** * Syncs a plugin to the correct Cursor directories: - * - commands/*.md → .cursor/commands/marketplace-name/plugin-name/ - * - rules/*.mdc → .cursor/rules/marketplace-name/plugin-name/ - * - agents/*.md → .cursor/agents/marketplace-name/plugin-name/ - * - skills/*.md → .cursor/skills/marketplace-name/plugin-name/ - * - hooks/* → .cursor/hooks/marketplace-name/plugin-name/ + * - commands/*.md → .cursor/commands/aipm/marketplace-name/plugin-name/ + * - rules/*.mdc → .cursor/rules/aipm/marketplace-name/plugin-name/ + * - agents/*.md → .cursor/agents/aipm/marketplace-name/plugin-name/ + * - skills/*.md → .cursor/skills/aipm/marketplace-name/plugin-name/ + * - hooks/* → .cursor/hooks/aipm/marketplace-name/plugin-name/ */ export async function syncPluginToCursor( pluginPath: string, @@ -34,42 +35,42 @@ export async function syncPluginToCursor( hooksCount: 0, }; - // Sync commands/*.md to .cursor/commands/marketplace/plugin/ + // Sync commands/*.md to .cursor/commands/aipm/marketplace/plugin/ const commandsResult = await syncDirectory( join(pluginPath, 'commands'), - join(cursorDir, 'commands', marketplaceName, pluginName), + join(cursorDir, 'commands', DIR_AIPM_NAMESPACE, marketplaceName, pluginName), ['.md'], ); result.commandsCount = commandsResult; - // Sync rules/*.md and *.mdc to .cursor/rules/marketplace/plugin/ + // Sync rules/*.md and *.mdc to .cursor/rules/aipm/marketplace/plugin/ // Apply .cursor.yaml frontmatter overrides if they exist const rulesResult = await syncRulesDirectory( join(pluginPath, 'rules'), - join(cursorDir, 'rules', marketplaceName, pluginName), + join(cursorDir, 'rules', DIR_AIPM_NAMESPACE, marketplaceName, pluginName), ); result.rulesCount = rulesResult; - // Sync agents/*.md to .cursor/agents/marketplace/plugin/ + // Sync agents/*.md to .cursor/agents/aipm/marketplace/plugin/ const agentsResult = await syncDirectory( join(pluginPath, 'agents'), - join(cursorDir, 'agents', marketplaceName, pluginName), + join(cursorDir, 'agents', DIR_AIPM_NAMESPACE, marketplaceName, pluginName), ['.md'], ); result.agentsCount = agentsResult; - // Sync skills/*.md to .cursor/skills/marketplace/plugin/ + // Sync skills/*.md to .cursor/skills/aipm/marketplace/plugin/ const skillsResult = await syncDirectory( join(pluginPath, 'skills'), - join(cursorDir, 'skills', marketplaceName, pluginName), + join(cursorDir, 'skills', DIR_AIPM_NAMESPACE, marketplaceName, pluginName), ['.md'], ); result.skillsCount = skillsResult; - // Sync hooks/* to .cursor/hooks/marketplace/plugin/ + // Sync hooks/* to .cursor/hooks/aipm/marketplace/plugin/ const hooksResult = await syncDirectory( join(pluginPath, 'hooks'), - join(cursorDir, 'hooks', marketplaceName, pluginName), + join(cursorDir, 'hooks', DIR_AIPM_NAMESPACE, marketplaceName, pluginName), [], // Copy all files in hooks ); result.hooksCount = hooksResult; diff --git a/tests/commands/claude-code-auto-discovery.test.ts b/tests/commands/claude-code-auto-discovery.test.ts index ea47b04..d13bacf 100644 --- a/tests/commands/claude-code-auto-discovery.test.ts +++ b/tests/commands/claude-code-auto-discovery.test.ts @@ -44,9 +44,9 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); - expect(config.marketplaces['claude:anthropic-agent-skills']?.source).toBe('directory'); - expect(config.marketplaces['claude:anthropic-agent-skills']?.path).toContain('anthropic-agent-skills'); - expect(config.marketplaces['claude:claude-code-workflows']).toBeDefined(); + expect(config.marketplaces['claude/anthropic-agent-skills']?.source).toBe('directory'); + expect(config.marketplaces['claude/anthropic-agent-skills']?.path).toContain('anthropic-agent-skills'); + expect(config.marketplaces['claude/claude-code-workflows']).toBeDefined(); }); test('Claude Code marketplaces appear in aipm list command', async () => { @@ -100,7 +100,7 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); - expect(config.marketplaces['claude:test-marketplace']?.source).toBe('directory'); + expect(config.marketplaces['claude/test-marketplace']?.source).toBe('directory'); }); test('does not interfere with manually configured AIPM marketplaces', async () => { @@ -134,7 +134,7 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); expect(config.marketplaces['my-marketplace']).toBeDefined(); - expect(config.marketplaces['claude:claude-marketplace']).toBeDefined(); + expect(config.marketplaces['claude/claude-marketplace']).toBeDefined(); }); test('handles missing Claude Code installation gracefully', async () => { @@ -143,7 +143,7 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); expect(config).not.toBeNull(); - const claudeMarketplaces = Object.keys(config.marketplaces || {}).filter((name) => name.startsWith('claude:')); + const claudeMarketplaces = Object.keys(config.marketplaces || {}).filter((name) => name.startsWith('claude/')); expect(claudeMarketplaces).toHaveLength(0); }); @@ -165,7 +165,7 @@ describe('Claude Code Auto-Discovery Integration', () => { const configPath = join(testDir, '.aipm', 'config.json'); const aipmConfig = { marketplaces: { - 'claude:my-marketplace': { + 'claude/my-marketplace': { source: 'directory', path: './aipm-marketplace', }, @@ -177,7 +177,7 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); - expect(config.marketplaces['claude:my-marketplace']?.path).toBe('./aipm-marketplace'); + expect(config.marketplaces['claude/my-marketplace']?.path).toBe('./aipm-marketplace'); }); test('discovers git-based Claude Code marketplace', async () => { @@ -198,8 +198,8 @@ describe('Claude Code Auto-Discovery Integration', () => { const { config } = await loadPluginsConfig(testDir); - expect(config.marketplaces['claude:git-marketplace']?.source).toBe('git'); - expect(config.marketplaces['claude:git-marketplace']?.url).toBe('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/org/marketplace.git'); - expect(config.marketplaces['claude:git-marketplace']?.branch).toBe('main'); + expect(config.marketplaces['claude/git-marketplace']?.source).toBe('git'); + expect(config.marketplaces['claude/git-marketplace']?.url).toBe('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/org/marketplace.git'); + expect(config.marketplaces['claude/git-marketplace']?.branch).toBe('main'); }); }); diff --git a/tests/commands/plugin-install.test.ts b/tests/commands/plugin-install.test.ts index 77fa1a2..e6f8565 100644 --- a/tests/commands/plugin-install.test.ts +++ b/tests/commands/plugin-install.test.ts @@ -51,7 +51,7 @@ describe('plugin-install', () => { expect(config.plugins['test-plugin@local']).toBeDefined(); expect(config.plugins['test-plugin@local'].enabled).toBe(true); - const commandsPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); @@ -200,7 +200,7 @@ describe('plugin-install', () => { await pluginInstall(options); - const commandsPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); }); @@ -293,7 +293,7 @@ describe('plugin-install', () => { const config = JSON.parse(await Bun.file(pluginsPath).text()); expect(config).toEqual(originalConfig); - const commandsPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin'); expect(await fileExists(commandsPath)).toBe(false); }); }); @@ -348,7 +348,7 @@ describe('plugin-install', () => { await pluginInstall(options); - const commandsPath = join(testDir, '.cursor', 'commands', 'local', 'custom-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'custom-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); }); diff --git a/tests/commands/plugin-uninstall.test.ts b/tests/commands/plugin-uninstall.test.ts index 484ae00..c76109c 100644 --- a/tests/commands/plugin-uninstall.test.ts +++ b/tests/commands/plugin-uninstall.test.ts @@ -130,7 +130,7 @@ describe('plugin-uninstall', () => { }), ); - const pluginDir = join(testDir, '.cursor', 'marketplace', 'local', 'my-plugin'); + const pluginDir = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'my-plugin'); await mkdir(pluginDir, { recursive: true }); await writeFile(join(pluginDir, 'test.txt'), 'test content'); @@ -164,7 +164,7 @@ describe('plugin-uninstall', () => { }), ); - const pluginDir = join(testDir, '.cursor', 'marketplace', 'local', 'my-plugin'); + const pluginDir = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'my-plugin'); await mkdir(pluginDir, { recursive: true }); await writeFile(join(pluginDir, 'test.txt'), 'test content'); @@ -248,7 +248,7 @@ describe('plugin-uninstall', () => { }), ); - const pluginDir = join(testDir, '.cursor', 'marketplace', 'local', 'my-plugin'); + const pluginDir = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'my-plugin'); await mkdir(pluginDir, { recursive: true }); await writeFile(join(pluginDir, 'test.txt'), 'test content'); @@ -289,7 +289,7 @@ describe('plugin-uninstall', () => { }), ); - const pluginDir = join(testDir, '.cursor', 'marketplace'); + const pluginDir = join(testDir, '.cursor', 'commands', 'aipm'); await mkdir(pluginDir, { recursive: true }); const options = { diff --git a/tests/commands/plugin-update.test.ts b/tests/commands/plugin-update.test.ts index 891bbb1..5ca2839 100644 --- a/tests/commands/plugin-update.test.ts +++ b/tests/commands/plugin-update.test.ts @@ -49,7 +49,7 @@ describe('plugin-update', () => { await pluginUpdate(options); - const updatedCommandPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'updated.md'); + const updatedCommandPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'updated.md'); expect(await fileExists(updatedCommandPath)).toBe(true); }); @@ -179,8 +179,8 @@ describe('plugin-update', () => { }), ); - await mkdir(join(testDir, '.cursor', 'commands', 'local', 'test-plugin'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'), '# Old version'); + await mkdir(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin'), { recursive: true }); + await writeFile(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'), '# Old version'); const options = { pluginId: 'test-plugin@local', @@ -190,7 +190,7 @@ describe('plugin-update', () => { await pluginUpdate(options); - const newCommandPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'new.md'); + const newCommandPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'new.md'); expect(await fileExists(newCommandPath)).toBe(false); }); }); @@ -248,7 +248,7 @@ describe('plugin-update', () => { await pluginUpdate(options); - const updatedCommandPath = join(testDir, '.cursor', 'commands', 'local', 'custom-plugin', 'updated.md'); + const updatedCommandPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'custom-plugin', 'updated.md'); expect(await fileExists(updatedCommandPath)).toBe(true); }); }); diff --git a/tests/commands/sync-git.test.ts b/tests/commands/sync-git.test.ts index 314af42..35d6df4 100644 --- a/tests/commands/sync-git.test.ts +++ b/tests/commands/sync-git.test.ts @@ -75,7 +75,7 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - const commandsPath = join(testDir, '.cursor', 'commands', 'git', 'git-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'git', 'git-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); @@ -97,9 +97,9 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'git', 'plugin-a', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'git', 'plugin-b', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'git', 'plugin-c', 'test.md'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'git', 'plugin-a', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'git', 'plugin-b', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'git', 'plugin-c', 'test.md'))).toBe(false); }); test('pulls updates from git marketplace on subsequent syncs', async () => { @@ -118,7 +118,7 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - const commandsPath = join(testDir, '.cursor', 'commands', 'git', 'update-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'git', 'update-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); await writeFile(join(gitRepoDir, 'update-plugin', 'commands', 'new-file.md'), '# New'); @@ -129,7 +129,7 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - const newFilePath = join(testDir, '.cursor', 'commands', 'git', 'update-plugin', 'new-file.md'); + const newFilePath = join(testDir, '.cursor', 'commands', 'aipm', 'git', 'update-plugin', 'new-file.md'); expect(await fileExists(newFilePath)).toBe(true); }); @@ -157,7 +157,7 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - const featurePath = join(testDir, '.cursor', 'commands', 'git', 'branch-plugin', 'feature.md'); + const featurePath = join(testDir, '.cursor', 'commands', 'aipm', 'git', 'branch-plugin', 'feature.md'); expect(await fileExists(featurePath)).toBe(true); }); @@ -196,8 +196,10 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'git', 'git-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'local-plugin', 'local-test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'git', 'git-plugin', 'test.md'))).toBe(true); + expect( + await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'local-plugin', 'local-test.md')), + ).toBe(true); }); test('handles git marketplace without url gracefully', async () => { @@ -233,6 +235,6 @@ describe('sync command with git sources', () => { await sync({ cwd: testDir, dryRun: true }); expect(await fileExists(join(cacheDir, 'git'))).toBe(false); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'git', 'dry-plugin'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'git', 'dry-plugin'))).toBe(false); }); }); diff --git a/tests/commands/sync-marketplace-json.test.ts b/tests/commands/sync-marketplace-json.test.ts index 886afdc..61f805b 100644 --- a/tests/commands/sync-marketplace-json.test.ts +++ b/tests/commands/sync-marketplace-json.test.ts @@ -77,7 +77,7 @@ describe('sync command with marketplace.json', () => { await sync({ cwd: testDir }); - const commandsPath = join(testDir, '.cursor', 'commands', 'curated', 'my-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'curated', 'my-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); @@ -97,7 +97,7 @@ describe('sync command with marketplace.json', () => { await sync({ cwd: testDir }); - const commandsPath = join(testDir, '.cursor', 'commands', 'unstructured', 'my-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'unstructured', 'my-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); @@ -136,13 +136,13 @@ describe('sync command with marketplace.json', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'selective', 'included-plugin', 'test.md'))).toBe( - true, - ); + expect( + await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'selective', 'included-plugin', 'test.md')), + ).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'selective', 'excluded-plugin', 'test.md'))).toBe( - false, - ); + expect( + await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'selective', 'excluded-plugin', 'test.md')), + ).toBe(false); }); test('handles nested plugin paths in marketplace.json', async () => { @@ -174,7 +174,9 @@ describe('sync command with marketplace.json', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'monorepo', 'analyzer', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'monorepo', 'analyzer', 'test.md'))).toBe( + true, + ); }); test('syncs multiple plugins with different paths', async () => { @@ -203,7 +205,7 @@ describe('sync command with marketplace.json', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'mixed', 'plugin-a', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'mixed', 'plugin-b', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'mixed', 'plugin-a', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'mixed', 'plugin-b', 'test.md'))).toBe(true); }); }); diff --git a/tests/commands/sync-rules.test.ts b/tests/commands/sync-rules.test.ts index e526b48..c9fa96e 100644 --- a/tests/commands/sync-rules.test.ts +++ b/tests/commands/sync-rules.test.ts @@ -59,7 +59,7 @@ Rule content here.`, await sync({ cwd: testDir }); - const outputPath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'my-rule.mdc'); + const outputPath = join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'my-rule.mdc'); expect(await fileExists(outputPath)).toBe(true); @@ -103,7 +103,7 @@ setting: value await sync({ cwd: testDir }); - const outputPath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'my-rule.mdc'); + const outputPath = join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'my-rule.mdc'); expect(await fileExists(outputPath)).toBe(true); @@ -159,7 +159,7 @@ priority: high`, await sync({ cwd: testDir }); - const outputPath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'my-rule.mdc'); + const outputPath = join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'my-rule.mdc'); expect(await fileExists(outputPath)).toBe(true); @@ -227,7 +227,7 @@ alwaysApply: false await sync({ cwd: testDir }); // Check rule with override - const overridePath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'rule-with-override.mdc'); + const overridePath = join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'rule-with-override.mdc'); expect(await fileExists(overridePath)).toBe(true); const overrideContent = await readFile(overridePath, 'utf-8'); expect(overrideContent).toContain('cursorMode: strict'); @@ -235,7 +235,15 @@ alwaysApply: false expect(overrideContent).toContain('# Rule With Override'); // Check rule without override - const noOverridePath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'rule-without-override.mdc'); + const noOverridePath = join( + testDir, + '.cursor', + 'rules', + 'aipm', + 'local', + 'test-plugin', + 'rule-without-override.mdc', + ); expect(await fileExists(noOverridePath)).toBe(true); const noOverrideContent = await readFile(noOverridePath, 'utf-8'); expect(noOverrideContent).toContain('alwaysApply: false'); @@ -270,7 +278,7 @@ alwaysApply: false await sync({ cwd: testDir }); - const cursorYamlPath = join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'my-rule.cursor.yaml'); + const cursorYamlPath = join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'my-rule.cursor.yaml'); expect(await fileExists(cursorYamlPath)).toBe(false); }); diff --git a/tests/commands/sync.test.ts b/tests/commands/sync.test.ts index 2ca2d22..9b9e160 100644 --- a/tests/commands/sync.test.ts +++ b/tests/commands/sync.test.ts @@ -54,7 +54,7 @@ describe('sync command', () => { await sync({ cwd: testDir }); - const commandsPath = join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'); + const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'); expect(await fileExists(commandsPath)).toBe(true); }); @@ -77,8 +77,8 @@ describe('sync command', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'plugin1', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'plugin2', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'plugin1', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'plugin2', 'test.md'))).toBe(true); }); test('only syncs enabled plugins', async () => { @@ -105,8 +105,10 @@ describe('sync command', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'enabled', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'disabled', 'test.md'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'enabled', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'disabled', 'test.md'))).toBe( + false, + ); }); test('clears marketplace directory before syncing', async () => { @@ -123,7 +125,7 @@ describe('sync command', () => { await sync({ cwd: testDir }); - const oldPluginPath = join(testDir, '.cursor', 'commands', 'local', 'plugin1', 'test.md'); + const oldPluginPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'plugin1', 'test.md'); expect(await fileExists(oldPluginPath)).toBe(true); await createMockPlugin('plugin2'); @@ -139,8 +141,8 @@ describe('sync command', () => { await sync({ cwd: testDir }); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'plugin1', 'test.md'))).toBe(false); - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'plugin2', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'plugin1', 'test.md'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'plugin2', 'test.md'))).toBe(true); }); }); @@ -345,8 +347,12 @@ describe('sync command', () => { await sync({ cwd: testDir }); // Nothing should be synced - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'))).toBe(false); - expect(await fileExists(join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'test.mdc'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + false, + ); + expect(await fileExists(join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'test.mdc'))).toBe( + false, + ); }); test('syncs all types when include is "all"', async () => { @@ -370,11 +376,19 @@ describe('sync command', () => { await sync({ cwd: testDir }); // All types should be synced - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'test.mdc'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'agents', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'skills', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'hooks', 'local', 'test-plugin', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'test.mdc'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'agents', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'skills', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'hooks', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe(true); }); test('syncs only enabled types when include is object', async () => { @@ -409,11 +423,15 @@ describe('sync command', () => { await sync({ cwd: testDir }); // Only rules and commands should be synced - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'test.mdc'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'agents', 'local', 'test-plugin'))).toBe(false); - expect(await fileExists(join(testDir, '.cursor', 'skills', 'local', 'test-plugin'))).toBe(false); - expect(await fileExists(join(testDir, '.cursor', 'hooks', 'local', 'test-plugin'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'test.mdc'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'agents', 'aipm', 'local', 'test-plugin'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'skills', 'aipm', 'local', 'test-plugin'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'hooks', 'aipm', 'local', 'test-plugin'))).toBe(false); }); test('defaults to true for unspecified types in include object', async () => { @@ -444,11 +462,17 @@ describe('sync command', () => { await sync({ cwd: testDir }); // Everything except agents should be synced (default to true) - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'test.mdc'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'skills', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'hooks', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'agents', 'local', 'test-plugin'))).toBe(false); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'test.mdc'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'skills', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'hooks', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'agents', 'aipm', 'local', 'test-plugin'))).toBe(false); }); test('syncs all types by default when no integrations config', async () => { @@ -467,11 +491,19 @@ describe('sync command', () => { await sync({ cwd: testDir }); // All types should be synced - expect(await fileExists(join(testDir, '.cursor', 'commands', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'rules', 'local', 'test-plugin', 'test.mdc'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'agents', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'skills', 'local', 'test-plugin', 'test.md'))).toBe(true); - expect(await fileExists(join(testDir, '.cursor', 'hooks', 'local', 'test-plugin', 'test.md'))).toBe(true); + expect(await fileExists(join(testDir, '.cursor', 'commands', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'rules', 'aipm', 'local', 'test-plugin', 'test.mdc'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'agents', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'skills', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe( + true, + ); + expect(await fileExists(join(testDir, '.cursor', 'hooks', 'aipm', 'local', 'test-plugin', 'test.md'))).toBe(true); }); }); });