Skip to content

Commit 77d1375

Browse files
committed
fix(plugin-uninstall): prevent orphaned skill dirs with conflicting plugin names
When uninstalling a plugin whose name is a prefix of another (e.g., my-plugin when my-plugin-extra exists), the previous fix used exact matching only (entry.name === flattenedPrefix), which never matched actual skill directories since skills always have suffixes (aipm-marketplace-plugin-skill1). This caused all skill directories to remain orphaned instead of being deleted. The fix reads the plugin's actual skills directory and builds the exact list of flattened skill names to delete when conflicts exist, ensuring precise cleanup without false positives.
1 parent 790cd3b commit 77d1375

File tree

1 file changed

+71
-55
lines changed

1 file changed

+71
-55
lines changed

src/commands/plugin-uninstall.ts

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,77 @@ export async function pluginUninstall(options: unknown): Promise<void> {
147147
}
148148
}
149149
}
150+
151+
// Remove flattened skill directories for this plugin
152+
// Pattern: .cursor/skills/aipm-<marketplace>-<plugin>[-subskill]
153+
// Must avoid prefix collisions when plugin names are prefixes of others
154+
// (e.g., "my-plugin" shouldn't match "my-plugin-extra")
155+
const skillsDir = join(cwd, DIR_CURSOR, DIR_SKILLS);
156+
try {
157+
const entries = await readdir(skillsDir, { withFileTypes: true });
158+
const flattenedPrefix = `aipm-${marketplaceName.replace(/\//g, '-')}-${pluginName}`;
159+
160+
// Check for conflicting plugin names in this marketplace
161+
const otherPluginsInMarketplace = Object.keys(config.plugins).filter(
162+
(id) => id.endsWith(`@${marketplaceName}`) && id !== cmd.pluginId,
163+
);
164+
165+
const hasConflictingNames = otherPluginsInMarketplace.some((id) => {
166+
const otherPluginName = id.substring(0, id.lastIndexOf('@'));
167+
return otherPluginName.startsWith(pluginName) && otherPluginName !== pluginName;
168+
});
169+
170+
// When conflicts exist, we need exact skill information to avoid false positives
171+
// Read the plugin's skills directory to get the exact list
172+
const skillsToDelete = new Set<string>();
173+
174+
if (hasConflictingNames && pluginPath) {
175+
// Load exact skill information from plugin directory
176+
const skillsSourcePath = join(pluginPath, 'skills');
177+
try {
178+
const skillEntries = await readdir(skillsSourcePath, { withFileTypes: true });
179+
for (const skillEntry of skillEntries) {
180+
if (skillEntry.isDirectory()) {
181+
// Reconstruct the exact flattened name for this skill
182+
const flatSkillName = `${flattenedPrefix}-${skillEntry.name}`;
183+
skillsToDelete.add(flatSkillName);
184+
}
185+
}
186+
} catch {
187+
// No skills directory - plugin has no skills
188+
}
189+
}
190+
191+
for (const entry of entries) {
192+
if (!entry.isDirectory()) continue;
193+
194+
let shouldDelete = false;
195+
196+
if (hasConflictingNames) {
197+
// With conflicts, only delete skills we explicitly found in the plugin directory
198+
shouldDelete = skillsToDelete.has(entry.name);
199+
} else {
200+
// No conflicts - safe to use prefix matching
201+
shouldDelete = entry.name === flattenedPrefix || entry.name.startsWith(flattenedPrefix + '-');
202+
}
203+
204+
if (shouldDelete) {
205+
try {
206+
await rm(join(skillsDir, entry.name), { recursive: true });
207+
deletedCount++;
208+
} catch (error) {
209+
if (!isFileNotFoundError(error)) {
210+
throw error;
211+
}
212+
}
213+
}
214+
}
215+
} catch (error) {
216+
// Ignore errors reading skills directory
217+
if (!isFileNotFoundError(error)) {
218+
throw error;
219+
}
220+
}
150221
}
151222
} else {
152223
// Marketplace config missing - can still delete AIPM plugins, but not Claude Code meta-plugins
@@ -174,61 +245,6 @@ export async function pluginUninstall(options: unknown): Promise<void> {
174245
}
175246
}
176247

177-
// Remove flattened skill directories for this plugin
178-
// Pattern: .cursor/skills/aipm-<marketplace>-<plugin>[-subskill]
179-
// Must avoid prefix collisions when plugin names are prefixes of others
180-
// (e.g., "my-plugin" shouldn't match "my-plugin-extra")
181-
// Only safe to delete when we have accurate skill information from the manifest
182-
if (marketplaceConfig) {
183-
const skillsDir = join(cwd, DIR_CURSOR, DIR_SKILLS);
184-
try {
185-
const entries = await readdir(skillsDir, { withFileTypes: true });
186-
const flattenedPrefix = `aipm-${marketplaceName.replace(/\//g, '-')}-${pluginName}`;
187-
188-
// Only use prefix matching if no other plugins in this marketplace start with our plugin name
189-
// This prevents accidentally deleting my-plugin-extra's skills when uninstalling my-plugin
190-
const otherPluginsInMarketplace = Object.keys(config.plugins).filter(
191-
(id) => id.endsWith(`@${marketplaceName}`) && id !== cmd.pluginId,
192-
);
193-
194-
const hasConflictingNames = otherPluginsInMarketplace.some((id) => {
195-
const otherPluginName = id.substring(0, id.lastIndexOf('@'));
196-
return otherPluginName.startsWith(pluginName) && otherPluginName !== pluginName;
197-
});
198-
199-
for (const entry of entries) {
200-
let shouldDelete = false;
201-
202-
if (entry.isDirectory()) {
203-
if (hasConflictingNames) {
204-
// If there are plugins with conflicting names, only delete exact match for root directory
205-
// This is safer than trying to pattern-match when names could be ambiguous
206-
shouldDelete = entry.name === flattenedPrefix;
207-
} else {
208-
// No conflicting names - safe to use prefix matching
209-
shouldDelete = entry.name === flattenedPrefix || entry.name.startsWith(flattenedPrefix + '-');
210-
}
211-
}
212-
213-
if (shouldDelete) {
214-
try {
215-
await rm(join(skillsDir, entry.name), { recursive: true });
216-
deletedCount++;
217-
} catch (error) {
218-
if (!isFileNotFoundError(error)) {
219-
throw error;
220-
}
221-
}
222-
}
223-
}
224-
} catch (error) {
225-
// Ignore errors reading skills directory
226-
if (!isFileNotFoundError(error)) {
227-
throw error;
228-
}
229-
}
230-
}
231-
232248
if (deletedCount > 0) {
233249
defaultIO.logSuccess(`Deleted plugin files from ${deletedCount} location(s) in .cursor/`);
234250
}

0 commit comments

Comments
 (0)