Skip to content

Commit 41299d4

Browse files
authored
feat(sync): add orphan symlink cleanup module for uninstalled plugins (#16)
Adds capability to detect and remove symlinks pointing to uninstalled plugins by validating against ~/.claude/plugins/installed_plugins.json. Key features: - Extract plugin names from cache and marketplace symlink paths - Read and parse installed_plugins.json manifest - Identify orphaned symlinks not in installed plugins list - Handle edge cases: relative paths, arrays, parse errors Note: Module is available but not called in sync flow since clean-slate approach already removes all symlinks. Ready for future incremental sync. Closes #15
1 parent 0c2394c commit 41299d4

4 files changed

Lines changed: 730 additions & 3 deletions

File tree

‎src/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
236236
(client as unknown as { app: { log: (msg: string) => void } }).app.log(
237237
"Claude Code not installed, skipping"
238238
)
239-
return
240239
}
241240

242241
// Find skills from cache first (higher priority), then marketplaces
@@ -273,7 +272,6 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
273272
// Step 1: Clean all existing symlinks (safety-first)
274273
let cleaned = 0
275274
let created = 0
276-
277275
if (await exists(targetDir)) {
278276
const entries = await readdir(targetDir)
279277

@@ -308,6 +306,10 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
308306
`Synced ${totalFound} skills (limit: ${MAX_SKILLS}): ` +
309307
`${created} created, ${cleaned} cleaned`
310308
)
309+
310+
// Note: Orphan cleanup is not needed here because the clean-slate sync approach
311+
// (wiping all symlinks, then recreating) inherently prevents orphan symlinks.
312+
// The orphan-cleanup module exists for future incremental sync implementations.
311313
} catch (err) {
312314
console.error("[claude-skill-sync] Sync failed:", err)
313315
}

‎src/orphan-cleanup.ts‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { join, resolve, dirname } from "path"
2+
3+
/**
4+
* Extracts plugin name from symlink target path.
5+
*
6+
* Supports two path formats:
7+
* - Cache: `/cache/{marketplace}/{plugin}/{version}/skills/{skill}` → `{plugin}@{marketplace}`
8+
* - Marketplace: `/marketplaces/{marketplace}/plugins/{plugin}/` → `{plugin}@{marketplace}`
9+
*
10+
* @param path - Symlink target path
11+
* @returns Plugin name in format `{plugin}@{marketplace}` or null if unparseable
12+
*/
13+
export function extractPluginNameFromPath(path: string): string | null {
14+
// Normalize path - remove trailing slashes and handle both forward/backward slashes
15+
const normalizedPath = path.replace(/[\\/]+$/, "").replace(/\\/g, "/")
16+
17+
// Try cache format: /cache/{marketplace}/{plugin}/{version}/skills/{skill}
18+
const cacheMatch = normalizedPath.match(/(?:^|\/)cache\/([^/]+)\/([^/]+)\/[^/]*\/skills\//)
19+
if (cacheMatch) {
20+
const marketplace = cacheMatch[1]
21+
const plugin = cacheMatch[2]
22+
return `${plugin}@${marketplace}`
23+
}
24+
25+
// Try marketplace plugins format: /marketplaces/{marketplace}/plugins/{plugin}/
26+
const marketplaceMatch = normalizedPath.match(
27+
/(?:^|\/)marketplaces\/([^/]+)\/plugins\/([^/]+)(?:\/|$)/
28+
)
29+
if (marketplaceMatch) {
30+
const marketplace = marketplaceMatch[1]
31+
const plugin = marketplaceMatch[2]
32+
return `${plugin}@${marketplace}`
33+
}
34+
35+
// Try marketplace direct skills format: /marketplaces/{marketplace}/skills/
36+
const directSkillsMatch = normalizedPath.match(/(?:^|\/)marketplaces\/([^/]+)\/skills\//)
37+
if (directSkillsMatch) {
38+
const marketplace = directSkillsMatch[1]
39+
return `${marketplace}@${marketplace}` // Fallback: use marketplace name as both plugin and marketplace
40+
}
41+
42+
// Could not parse path
43+
return null
44+
}
45+
46+
/**
47+
* InstallePlugins JSON interface
48+
*/
49+
interface InstalledPluginsManifest {
50+
version: number
51+
plugins: Record<string, unknown>
52+
}
53+
54+
/**
55+
* Reads installed plugins from JSON content.
56+
*
57+
* @param content - JSON string from installed_plugins.json
58+
* @returns Set of plugin keys, or null if parse fails
59+
*/
60+
export function readInstalledPlugins(content: string): Set<string> | null {
61+
try {
62+
const parsed = JSON.parse(content) as unknown
63+
64+
// Validate basic structure
65+
if (!parsed || typeof parsed !== "object") {
66+
return null
67+
}
68+
69+
const manifest = parsed as InstalledPluginsManifest
70+
71+
// Check if plugins key exists and is an object (not array, not null, not primitive)
72+
if (
73+
!manifest.plugins ||
74+
typeof manifest.plugins !== "object" ||
75+
Array.isArray(manifest.plugins)
76+
) {
77+
return null
78+
}
79+
80+
// Extract plugin keys
81+
const pluginKeys = Object.keys(manifest.plugins)
82+
return new Set(pluginKeys)
83+
} catch {
84+
// JSON parse error or structure validation failed
85+
return null
86+
}
87+
}
88+
89+
/**
90+
* Filesystem interface for testing
91+
*/
92+
export interface FSOperations {
93+
readdir: (path: string) => Promise<string[]>
94+
lstat: (path: string) => Promise<{ isDirectory: () => boolean; isSymbolicLink: () => boolean }>
95+
readlink: (path: string) => Promise<string>
96+
unlink: (path: string) => Promise<void>
97+
}
98+
99+
/**
100+
* Cleans up orphaned symlinks from uninstalled plugins.
101+
*
102+
* @param targetDir - Directory containing symlinks to clean
103+
* @param installedPlugins - Set of installed plugin keys
104+
* @param fs - Filesystem operations (allows mocking for tests)
105+
* @returns Number of symlinks removed
106+
*/
107+
export async function cleanupOrphanedSymlinks(
108+
targetDir: string,
109+
installedPlugins: Set<string>,
110+
fs: FSOperations
111+
): Promise<number> {
112+
let removed = 0
113+
114+
try {
115+
const entries = await fs.readdir(targetDir)
116+
117+
for (const entry of entries) {
118+
try {
119+
const entryPath = join(targetDir, entry)
120+
const stats = await fs.lstat(entryPath)
121+
122+
// Only process symlinks, skip regular files and directories
123+
if (!stats.isSymbolicLink()) {
124+
continue
125+
}
126+
127+
// Read the symlink target to extract plugin name
128+
const rawTargetPath = await fs.readlink(entryPath)
129+
// Normalize relative symlinks: resolve against the symlink's directory
130+
const normalizedPath =
131+
rawTargetPath.startsWith("/") || rawTargetPath.match(/^[A-Za-z]:/)
132+
? rawTargetPath
133+
: resolve(dirname(entryPath), rawTargetPath)
134+
const pluginName = extractPluginNameFromPath(normalizedPath)
135+
136+
// Remove orphaned symlinks where plugin is not installed
137+
if (pluginName === null || !installedPlugins.has(pluginName)) {
138+
await fs.unlink(entryPath)
139+
removed++
140+
}
141+
} catch {
142+
// Skip individual entry errors (e.g., broken symlinks, readlink failures)
143+
continue
144+
}
145+
}
146+
} catch {
147+
// If directory read fails, return 0 (fail safe)
148+
return 0
149+
}
150+
151+
return removed
152+
}

‎tests/mocks.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface StatResult {
2929
isSymbolicLink: () => boolean
3030
}
3131

32-
interface MockFSMethods {
32+
export interface MockFSMethods {
3333
access: ReturnType<typeof vi.fn>
3434
readdir: ReturnType<typeof vi.fn>
3535
stat: ReturnType<typeof vi.fn>

0 commit comments

Comments
 (0)