Skip to content

Commit 06f36ff

Browse files
authored
fix: installing claude code plugins (#39)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 8207127 commit 06f36ff

File tree

11 files changed

+650
-34
lines changed

11 files changed

+650
-34
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
4040
# TrogonAI internal files
4141
.trogonai/
4242
*.internal.trogonai.md
43+
44+
# AIPM local configuration
45+
.aipm/config.local.json

src/commands/plugin-install.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { fileExists } from '../helpers/fs';
88
import { resolveMarketplacePath } from '../helpers/git';
99
import { defaultIO } from '../helpers/io';
1010
import { getMarketplaceType, loadMarketplaceManifest, resolvePluginPath } from '../helpers/marketplace';
11-
import { validatePluginStructure } from '../helpers/plugin';
12-
import { formatSyncResult, syncPluginToCursor } from '../helpers/sync-strategy';
11+
import { isMetaPlugin, validatePluginStructure } from '../helpers/plugin';
12+
import { formatSyncResult, syncMetaPluginToCursor, syncPluginToCursor } from '../helpers/sync-strategy';
1313

1414
const PluginInstallOptionsSchema = z.object({
1515
pluginId: z.string().min(1),
@@ -85,7 +85,8 @@ export async function pluginInstall(options: unknown): Promise<void> {
8585
}
8686

8787
try {
88-
await validatePluginStructure(pluginPath);
88+
const isMetaPluginCheck = isMetaPlugin(marketplacePath, pluginPath, manifest);
89+
await validatePluginStructure(pluginPath, { isMetaPlugin: isMetaPluginCheck });
8990
} catch (error: unknown) {
9091
const message = error instanceof Error ? error.message : String(error);
9192
const validationError = new Error(`Invalid plugin '${pluginName}': ${message}`);
@@ -122,7 +123,13 @@ export async function pluginInstall(options: unknown): Promise<void> {
122123
}
123124

124125
const cursorDir = join(cwd, DIR_CURSOR);
125-
const syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
126+
const isMetaPluginCheck = isMetaPlugin(marketplacePath, pluginPath, manifest);
127+
128+
const syncResult =
129+
isMetaPluginCheck && manifest
130+
? await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir)
131+
: await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
132+
126133
const summary = formatSyncResult(syncResult);
127134

128135
defaultIO.logSuccess(`Installed ${cmd.pluginId}`);

src/commands/plugin-uninstall.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,25 @@ import { rm } from 'node:fs/promises';
22
import { join } from 'node:path';
33
import { z } from 'zod';
44
import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader';
5-
import { DIR_AIPM_NAMESPACE, DIR_CURSOR, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL, PLUGIN_SUBDIRS } from '../constants';
5+
import {
6+
DIR_AIPM_NAMESPACE,
7+
DIR_CURSOR,
8+
DIR_SKILLS,
9+
FILE_AIPM_CONFIG,
10+
FILE_AIPM_CONFIG_LOCAL,
11+
PLUGIN_SUBDIRS,
12+
} from '../constants';
13+
import { isFileNotFoundError } from '../errors';
614
import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
7-
import { fileExists } from '../helpers/fs';
15+
import { resolveMarketplacePath } from '../helpers/git';
816
import { defaultIO } from '../helpers/io';
17+
import {
18+
getMarketplaceType,
19+
getPluginSourcePath,
20+
hasPluginName,
21+
loadMarketplaceManifest,
22+
} from '../helpers/marketplace';
23+
import { isMetaPlugin } from '../helpers/plugin';
924

1025
const PluginUninstallOptionsSchema = z.object({
1126
pluginId: z.string().min(1),
@@ -66,12 +81,83 @@ export async function pluginUninstall(options: unknown): Promise<void> {
6681
if (pluginName && marketplaceName) {
6782
let deletedCount = 0;
6883

69-
for (const subdir of PLUGIN_SUBDIRS) {
70-
const installedPath = join(cwd, DIR_CURSOR, subdir, DIR_AIPM_NAMESPACE, marketplaceName, pluginName);
84+
const marketplaceConfig = config.marketplaces[marketplaceName];
85+
const isClaudeCodeMarketplace = getMarketplaceType(marketplaceName) === 'claude';
7186

72-
if (await fileExists(installedPath)) {
73-
await rm(installedPath, { recursive: true, force: true });
74-
deletedCount++;
87+
if (marketplaceConfig) {
88+
const marketplacePath = await resolveMarketplacePath(marketplaceName, marketplaceConfig, cwd);
89+
if (!marketplacePath) {
90+
throw new Error(`Could not resolve marketplace path for '${marketplaceName}'`);
91+
}
92+
const manifest = await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName));
93+
const pluginPath = getPluginSourcePath(marketplacePath, pluginName, manifest);
94+
95+
const isMetaPluginCheck = isMetaPlugin(marketplacePath, pluginPath, manifest);
96+
97+
if (isMetaPluginCheck && manifest) {
98+
const pluginEntry = manifest.plugins.find(hasPluginName(pluginName));
99+
if (pluginEntry?.skills) {
100+
for (const skillPath of pluginEntry.skills) {
101+
const normalizedSkillPath = skillPath.replace(/^\.\//, '');
102+
const marketplaceSegments = marketplaceName.split('/');
103+
const installedPath = join(
104+
cwd,
105+
DIR_CURSOR,
106+
DIR_SKILLS,
107+
DIR_AIPM_NAMESPACE,
108+
...marketplaceSegments,
109+
normalizedSkillPath,
110+
);
111+
112+
try {
113+
await rm(installedPath, { recursive: true });
114+
deletedCount++;
115+
} catch (error) {
116+
// Ignore if path doesn't exist - that's fine
117+
if (!isFileNotFoundError(error)) {
118+
throw error;
119+
}
120+
}
121+
}
122+
}
123+
} else {
124+
for (const subdir of PLUGIN_SUBDIRS) {
125+
const installedPath = join(cwd, DIR_CURSOR, subdir, DIR_AIPM_NAMESPACE, marketplaceName, pluginName);
126+
127+
try {
128+
await rm(installedPath, { recursive: true });
129+
deletedCount++;
130+
} catch (error) {
131+
// Ignore if path doesn't exist - that's fine
132+
if (!isFileNotFoundError(error)) {
133+
throw error;
134+
}
135+
}
136+
}
137+
}
138+
} else {
139+
// Marketplace config missing - can still delete AIPM plugins, but not Claude Code meta-plugins
140+
if (isClaudeCodeMarketplace) {
141+
defaultIO.logInfo(
142+
`⚠️ Cannot delete files for Claude Code plugin '${cmd.pluginId}': ` +
143+
`marketplace '${marketplaceName}' not found in config. ` +
144+
`Manual cleanup may be required in .cursor/skills/aipm/${marketplaceName}/`,
145+
);
146+
} else {
147+
// AIPM plugins have predictable paths - delete them directly
148+
for (const subdir of PLUGIN_SUBDIRS) {
149+
const installedPath = join(cwd, DIR_CURSOR, subdir, DIR_AIPM_NAMESPACE, marketplaceName, pluginName);
150+
151+
try {
152+
await rm(installedPath, { recursive: true });
153+
deletedCount++;
154+
} catch (error) {
155+
// Ignore if path doesn't exist - that's fine
156+
if (!isFileNotFoundError(error)) {
157+
throw error;
158+
}
159+
}
160+
}
75161
}
76162
}
77163

src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export const DIR_AIPM_NAMESPACE = 'aipm';
1414
export const PLUGIN_SUBDIRS = ['commands', 'rules', 'agents', 'skills', 'hooks'] as const;
1515
export type PluginSubdir = (typeof PLUGIN_SUBDIRS)[number];
1616

17+
/**
18+
* Individual subdirectory names (for direct reference)
19+
*/
20+
export const DIR_SKILLS = 'skills' as const;
21+
1722
/**
1823
* Project-level AIPM directory
1924
*/

src/helpers/fs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { copyFile, mkdir, stat } from 'node:fs/promises';
22
import { dirname } from 'node:path';
33
import type { z } from 'zod';
4-
import { isNodeError } from '../errors';
4+
import { isFileNotFoundError, isNodeError } from '../errors';
55

66
export class JsonFileError extends Error {
77
constructor(
@@ -72,7 +72,8 @@ export async function backupFile(path: string, dryRun?: boolean): Promise<void>
7272
try {
7373
await copyFile(path, backupPath);
7474
} catch (error: unknown) {
75-
if (isNodeError(error) && error.code !== 'ENOENT') {
75+
// Ignore if file doesn't exist - nothing to backup
76+
if (!isFileNotFoundError(error)) {
7677
throw error;
7778
}
7879
}

src/helpers/marketplace.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export function getMarketplaceType(marketplaceName: string): MarketplaceType {
1414
return marketplaceName.startsWith('claude/') ? 'claude' : 'aipm';
1515
}
1616

17+
/**
18+
* Creates a predicate function to find a plugin by name.
19+
* @param name - The plugin name to search for
20+
* @returns A predicate function that returns true if the plugin name matches
21+
*/
22+
export const hasPluginName = (name: string) => (plugin: { name: string }) => plugin.name === name;
23+
1724
export async function loadAipmMarketplaceManifest(marketplacePath: string): Promise<MarketplaceManifest | null> {
1825
const manifestPath = join(marketplacePath, FILE_MARKETPLACE_MANIFEST);
1926

src/helpers/plugin.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { join } from 'node:path';
1+
import { join, normalize } from 'node:path';
22
import { DIR_CLAUDE_PLUGIN, FILE_PLUGIN_MANIFEST } from '../constants';
33
import { isFileNotFoundError, PluginManifestInvalidError, PluginManifestNotFoundError } from '../errors';
4-
import type { PluginManifest } from '../schema';
4+
import type { MarketplaceManifest, PluginManifest } from '../schema';
55
import { PluginManifestSchema } from '../schema';
66
import { readJsonFile } from './fs';
77

@@ -19,7 +19,31 @@ export async function loadPluginManifest(pluginPath: string): Promise<PluginMani
1919
}
2020
}
2121

22-
export async function validatePluginStructure(pluginPath: string): Promise<void> {
22+
export function isMetaPlugin(
23+
marketplacePath: string,
24+
pluginPath: string,
25+
marketplaceManifest: MarketplaceManifest | null,
26+
): boolean {
27+
if (!marketplaceManifest) {
28+
return false;
29+
}
30+
31+
const normalizedMarketplace = normalize(marketplacePath).replace(/\/$/, '');
32+
const normalizedPlugin = normalize(pluginPath).replace(/\/$/, '');
33+
34+
return normalizedMarketplace === normalizedPlugin;
35+
}
36+
37+
export async function validatePluginStructure(
38+
pluginPath: string,
39+
options?: {
40+
isMetaPlugin?: boolean;
41+
},
42+
): Promise<void> {
43+
if (options?.isMetaPlugin) {
44+
return;
45+
}
46+
2347
const manifest = await loadPluginManifest(pluginPath);
2448

2549
if (!manifest.name) {

src/helpers/sync-strategy.ts

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { cp, readdir, readFile, writeFile } from 'node:fs/promises';
22
import { basename, extname, join } from 'node:path';
3-
import { DIR_AIPM_NAMESPACE } from '../constants';
3+
import { DIR_AIPM_NAMESPACE, DIR_SKILLS } from '../constants';
44
import { DirectoryNotFoundError, isFileNotFoundError } from '../errors';
5+
import type { MarketplaceManifest } from '../schema';
56
import { applyCursorFrontmatter } from './frontmatter';
67
import { ensureDir, fileExists } from './fs';
8+
import { hasPluginName } from './marketplace';
79

8-
export type SyncResult = {
10+
export type AipmPluginSyncResult = {
11+
type: 'aipm';
912
commandsCount: number;
1013
rulesCount: number;
1114
agentsCount: number;
1215
skillsCount: number;
1316
hooksCount: number;
1417
};
1518

19+
export type ClaudeCodePluginSyncResult = {
20+
type: 'claudecode';
21+
skillsCount: number;
22+
};
23+
24+
export type SyncResult = AipmPluginSyncResult | ClaudeCodePluginSyncResult;
25+
1626
/**
1727
* Syncs a plugin to the correct Cursor directories:
1828
* - commands/*.md → .cursor/commands/aipm/marketplace-name/plugin-name/
@@ -26,8 +36,9 @@ export async function syncPluginToCursor(
2636
marketplaceName: string,
2737
pluginName: string,
2838
cursorDir: string,
29-
): Promise<SyncResult> {
30-
const result: SyncResult = {
39+
): Promise<AipmPluginSyncResult> {
40+
const result: AipmPluginSyncResult = {
41+
type: 'aipm',
3142
commandsCount: 0,
3243
rulesCount: 0,
3344
agentsCount: 0,
@@ -193,26 +204,65 @@ async function syncDirectory(sourceDir: string, targetDir: string, extensions: s
193204
return count;
194205
}
195206

207+
/**
208+
* Syncs a Claude Code meta-plugin to Cursor directories.
209+
* Meta-plugins point to the marketplace root and define skills in the manifest.
210+
* We sync each skill directory listed in the manifest's skills array.
211+
*/
212+
export async function syncMetaPluginToCursor(
213+
marketplacePath: string,
214+
marketplaceManifest: MarketplaceManifest,
215+
pluginName: string,
216+
marketplaceName: string,
217+
cursorDir: string,
218+
): Promise<ClaudeCodePluginSyncResult> {
219+
const pluginEntry = marketplaceManifest.plugins.find(hasPluginName(pluginName));
220+
if (!pluginEntry || !pluginEntry.skills) {
221+
return { type: 'claudecode', skillsCount: 0 };
222+
}
223+
224+
let skillsCount = 0;
225+
226+
for (const skillPath of pluginEntry.skills) {
227+
const normalizedSkillPath = skillPath.replace(/^\.\//, '');
228+
const fullSkillPath = join(marketplacePath, normalizedSkillPath);
229+
230+
const marketplaceSegments = marketplaceName.split('/');
231+
const targetPath = join(cursorDir, DIR_SKILLS, DIR_AIPM_NAMESPACE, ...marketplaceSegments, normalizedSkillPath);
232+
233+
const count = await syncDirectory(fullSkillPath, targetPath, ['.md']);
234+
skillsCount += count;
235+
}
236+
237+
return { type: 'claudecode', skillsCount };
238+
}
239+
196240
/**
197241
* Gets a summary string for sync results
198242
*/
199243
export function formatSyncResult(result: SyncResult): string {
200244
const parts: string[] = [];
201245

202-
if (result.commandsCount > 0) {
203-
parts.push(`${result.commandsCount} command(s)`);
204-
}
205-
if (result.rulesCount > 0) {
206-
parts.push(`${result.rulesCount} rule(s)`);
207-
}
208-
if (result.agentsCount > 0) {
209-
parts.push(`${result.agentsCount} agent(s)`);
210-
}
211-
if (result.skillsCount > 0) {
212-
parts.push(`${result.skillsCount} skill(s)`);
213-
}
214-
if (result.hooksCount > 0) {
215-
parts.push(`${result.hooksCount} hook(s)`);
246+
if (result.type === 'aipm') {
247+
if (result.commandsCount > 0) {
248+
parts.push(`${result.commandsCount} command(s)`);
249+
}
250+
if (result.rulesCount > 0) {
251+
parts.push(`${result.rulesCount} rule(s)`);
252+
}
253+
if (result.agentsCount > 0) {
254+
parts.push(`${result.agentsCount} agent(s)`);
255+
}
256+
if (result.skillsCount > 0) {
257+
parts.push(`${result.skillsCount} skill(s)`);
258+
}
259+
if (result.hooksCount > 0) {
260+
parts.push(`${result.hooksCount} hook(s)`);
261+
}
262+
} else if (result.type === 'claudecode') {
263+
if (result.skillsCount > 0) {
264+
parts.push(`${result.skillsCount} skill(s)`);
265+
}
216266
}
217267

218268
return parts.length > 0 ? parts.join(', ') : 'no files';

src/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const MarketplacePluginEntrySchema = z.object({
3131
email: z.string().optional(),
3232
})
3333
.optional(),
34+
skills: z.array(z.string()).optional(),
35+
strict: z.boolean().optional(),
3436
});
3537

3638
export const MarketplaceManifestSchema = z.object({

0 commit comments

Comments
 (0)