Skip to content

Commit 0c2394c

Browse files
authored
feat(sync): change target directory to OpenCode native path (#14)
* fix: sync skills to correct OpenCode directory (~/.config/opencode/skill) * feat(sync): change target directory to OpenCode native path - Change sync target from ~/.claude/skills to ~/.config/opencode/skill - Simplify symlink management with clean slate approach - Only remove symlinks, never regular files (safety-first) - Add comprehensive tests for symlink cleanup behavior Closes #13
1 parent 5210c81 commit 0c2394c

2 files changed

Lines changed: 201 additions & 42 deletions

File tree

src/index.ts

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2-
import {
3-
access,
4-
constants,
5-
lstat,
6-
readdir,
7-
readlink,
8-
mkdir,
9-
symlink,
10-
unlink,
11-
stat
12-
} from "fs/promises"
2+
import { access, constants, lstat, readdir, mkdir, symlink, unlink, stat } from "fs/promises"
133
import { join } from "path"
144
import { homedir } from "os"
155

@@ -235,12 +225,13 @@ async function findSkillsInMarketplaces(
235225
async function syncSkills(client: PluginInput["client"]): Promise<void> {
236226
const home = homedir()
237227
const claudeDir = join(home, ".claude")
228+
const opencodeDir = join(home, ".config", "opencode")
238229
const cacheDir = join(claudeDir, "plugins", "cache")
239230
const marketplacesDir = join(claudeDir, "plugins", "marketplaces")
240-
const targetDir = join(claudeDir, "skills")
231+
const targetDir = join(opencodeDir, "skill")
241232

242233
try {
243-
// Check if Claude directory exists
234+
// Check if Claude directory exists (required for plugin cache/marketplaces)
244235
if (!(await exists(claudeDir))) {
245236
(client as unknown as { app: { log: (msg: string) => void } }).app.log(
246237
"Claude Code not installed, skipping"
@@ -279,9 +270,8 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
279270
await mkdir(targetDir, { recursive: true })
280271
}
281272

282-
// Clean existing symlinks
273+
// Step 1: Clean all existing symlinks (safety-first)
283274
let cleaned = 0
284-
let updated = 0
285275
let created = 0
286276

287277
if (await exists(targetDir)) {
@@ -292,35 +282,18 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
292282
const entryPath = join(targetDir, entry)
293283
const lstats = await lstat(entryPath)
294284

285+
// ONLY remove symlinks, never remove regular files or directories
295286
if (lstats.isSymbolicLink()) {
296-
const target = await readlink(entryPath)
297-
const skill = skillMap.get(entry)
298-
299-
// Remove broken or stale symlinks
300-
const targetExists = await exists(entryPath)
301-
if (!targetExists || !skill) {
302-
await unlink(entryPath)
303-
cleaned++
304-
if (skill) skillMap.delete(entry)
305-
continue
306-
}
307-
308-
// Update if pointing to old version
309-
if (target !== skill.path) {
310-
await unlink(entryPath)
311-
await symlink(skill.path, entryPath)
312-
updated++
313-
}
314-
315-
skillMap.delete(entry)
287+
await unlink(entryPath)
288+
cleaned++
316289
}
317290
} catch {
318291
// Skip problematic entries
319292
}
320293
}
321294
}
322295

323-
// Create new symlinks
296+
// Step 2: Create fresh symlinks for all discovered skills
324297
for (const [name, skill] of skillMap) {
325298
try {
326299
const linkPath = join(targetDir, name)
@@ -333,7 +306,7 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
333306

334307
(client as unknown as { app: { log: (msg: string) => void } }).app.log(
335308
`Synced ${totalFound} skills (limit: ${MAX_SKILLS}): ` +
336-
`${created} created, ${updated} updated, ${cleaned} cleaned`
309+
`${created} created, ${cleaned} cleaned`
337310
)
338311
} catch (err) {
339312
console.error("[claude-skill-sync] Sync failed:", err)
@@ -343,8 +316,8 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
343316
/**
344317
* Claude Skill Sync Plugin
345318
*
346-
* Automatically discovers and syncs OpenCode plugin skills to the Claude Code
347-
* ~/.claude/skills directory via symlinks. Runs asynchronously to avoid blocking
319+
* Automatically discovers and syncs OpenCode plugin skills to the OpenCode
320+
* ~/.config/opencode/skill directory via symlinks. Runs asynchronously to avoid blocking
348321
* OpenCode startup.
349322
*
350323
* @example

tests/index.test.ts

Lines changed: 189 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,18 @@ describe("mock filesystem operations", () => {
163163
it("should detect symlink with lstat", async () => {
164164
mockFs.mockSymlinks.set("/link", "/source")
165165

166-
const stats = await mockFs.mocks.lstat("/link")
166+
const stats = (await mockFs.mocks.lstat("/link")) as { isSymbolicLink: () => boolean }
167167

168168
expect(stats.isSymbolicLink()).toBe(true)
169169
})
170170

171171
it("should detect directory with lstat", async () => {
172172
addDirStructure(mockFs, "/dir", [])
173173

174-
const stats = await mockFs.mocks.lstat("/dir")
174+
const stats = (await mockFs.mocks.lstat("/dir")) as {
175+
isDirectory: () => boolean
176+
isSymbolicLink: () => boolean
177+
}
175178

176179
expect(stats.isDirectory()).toBe(true)
177180
expect(stats.isSymbolicLink()).toBe(false)
@@ -250,7 +253,7 @@ describe("skill sync core logic", () => {
250253

251254
const versions = ["1.0.0", "2.1.3", "1.5.0", "0.9.1"]
252255
const sorted = versions.slice().sort(compareVersions)
253-
const latest = sorted[sorted.length - 1]
256+
const latest = sorted[sorted.length - 1] as string
254257

255258
expect(latest).toBe("2.1.3")
256259
})
@@ -456,3 +459,186 @@ describe("edge cases", () => {
456459
expect(mockFs.mockSymlinks.get("/final")).toBe("/source")
457460
})
458461
})
462+
463+
/**
464+
* Clean slate symlink management tests
465+
*/
466+
describe("symlink cleanup (clean slate)", () => {
467+
let mockFs: ReturnType<typeof createMockFilesystem>
468+
469+
beforeEach(() => {
470+
mockFs = createMockFilesystem()
471+
})
472+
473+
it("should remove all symlinks from target directory", async () => {
474+
const targetDir = "/skills"
475+
addDirStructure(mockFs, targetDir, ["skill1", "skill2", "skill3"])
476+
477+
// Add symlinks
478+
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")
479+
mockFs.mockSymlinks.set("/skills/skill2", "/cache/skill2")
480+
mockFs.mockSymlinks.set("/skills/skill3", "/cache/skill3")
481+
482+
// Simulate cleanup: remove all symlinks
483+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
484+
let cleaned = 0
485+
486+
for (const entry of entries) {
487+
const entryPath = join(targetDir, entry)
488+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
489+
490+
if (lstats.isSymbolicLink()) {
491+
await mockFs.mocks.unlink(entryPath)
492+
cleaned++
493+
}
494+
}
495+
496+
expect(cleaned).toBe(3)
497+
expect(mockFs.mockSymlinks.has("/skills/skill1")).toBe(false)
498+
expect(mockFs.mockSymlinks.has("/skills/skill2")).toBe(false)
499+
expect(mockFs.mockSymlinks.has("/skills/skill3")).toBe(false)
500+
})
501+
502+
it("should NOT remove regular files in target directory", async () => {
503+
const targetDir = "/skills"
504+
addDirStructure(mockFs, targetDir, ["regular-file.txt"])
505+
506+
// Add a regular file (not a symlink)
507+
mockFs.mockFiles.add("/skills/regular-file.txt")
508+
509+
// Simulate cleanup: only remove symlinks
510+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
511+
let cleaned = 0
512+
513+
for (const entry of entries) {
514+
const entryPath = join(targetDir, entry)
515+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
516+
517+
if (lstats.isSymbolicLink()) {
518+
await mockFs.mocks.unlink(entryPath)
519+
cleaned++
520+
}
521+
}
522+
523+
expect(cleaned).toBe(0)
524+
expect(mockFs.mockFiles.has("/skills/regular-file.txt")).toBe(true)
525+
})
526+
527+
it("should NOT remove directories in target directory", async () => {
528+
const targetDir = "/skills"
529+
addDirStructure(mockFs, targetDir, ["subdir"])
530+
addDirStructure(mockFs, "/skills/subdir", [])
531+
532+
// Simulate cleanup: only remove symlinks
533+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
534+
let cleaned = 0
535+
536+
for (const entry of entries) {
537+
const entryPath = join(targetDir, entry)
538+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
539+
540+
if (lstats.isSymbolicLink()) {
541+
await mockFs.mocks.unlink(entryPath)
542+
cleaned++
543+
}
544+
}
545+
546+
expect(cleaned).toBe(0)
547+
expect(mockFs.mockDirs.has("/skills/subdir")).toBe(true)
548+
})
549+
550+
it("should handle mixed symlinks and files", async () => {
551+
const targetDir = "/skills"
552+
addDirStructure(mockFs, targetDir, ["skill1", "file.txt", "skill2"])
553+
554+
// Add mixed content
555+
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")
556+
mockFs.mockFiles.add("/skills/file.txt")
557+
mockFs.mockSymlinks.set("/skills/skill2", "/cache/skill2")
558+
559+
// Simulate cleanup: only remove symlinks
560+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
561+
let cleaned = 0
562+
563+
for (const entry of entries) {
564+
const entryPath = join(targetDir, entry)
565+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
566+
567+
if (lstats.isSymbolicLink()) {
568+
await mockFs.mocks.unlink(entryPath)
569+
cleaned++
570+
}
571+
}
572+
573+
expect(cleaned).toBe(2)
574+
expect(mockFs.mockSymlinks.has("/skills/skill1")).toBe(false)
575+
expect(mockFs.mockSymlinks.has("/skills/skill2")).toBe(false)
576+
expect(mockFs.mockFiles.has("/skills/file.txt")).toBe(true)
577+
})
578+
579+
it("should create fresh symlinks for all skills after cleanup", async () => {
580+
const targetDir = "/skills"
581+
addDirStructure(mockFs, targetDir, ["old-skill"])
582+
583+
// Old symlink
584+
mockFs.mockSymlinks.set("/skills/old-skill", "/old/cache/skill")
585+
586+
// Simulate cleanup
587+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
588+
for (const entry of entries) {
589+
const entryPath = join(targetDir, entry)
590+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
591+
592+
if (lstats.isSymbolicLink()) {
593+
await mockFs.mocks.unlink(entryPath)
594+
}
595+
}
596+
597+
// Create new symlinks
598+
const skillMap = new Map<string, { path: string }>()
599+
skillMap.set("python-tdd", { path: "/cache/python-tdd" })
600+
skillMap.set("react-web", { path: "/cache/react-web" })
601+
602+
let created = 0
603+
for (const [name, skill] of skillMap) {
604+
const linkPath = join(targetDir, name)
605+
await mockFs.mocks.symlink(skill.path, linkPath)
606+
created++
607+
}
608+
609+
expect(created).toBe(2)
610+
expect(mockFs.mockSymlinks.get("/skills/python-tdd")).toBe("/cache/python-tdd")
611+
expect(mockFs.mockSymlinks.get("/skills/react-web")).toBe("/cache/react-web")
612+
expect(mockFs.mockSymlinks.has("/skills/old-skill")).toBe(false)
613+
})
614+
615+
it("should handle lstat errors gracefully during cleanup", async () => {
616+
const targetDir = "/skills"
617+
addDirStructure(mockFs, targetDir, ["skill1"])
618+
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")
619+
620+
// Spy on lstat to verify error handling
621+
const lstatSpy = vi.spyOn(mockFs.mocks, "lstat")
622+
623+
// Simulate cleanup with error handling
624+
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
625+
let cleaned = 0
626+
627+
for (const entry of entries) {
628+
try {
629+
const entryPath = join(targetDir, entry)
630+
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }
631+
632+
if (lstats.isSymbolicLink()) {
633+
await mockFs.mocks.unlink(entryPath)
634+
cleaned++
635+
}
636+
} catch {
637+
// Error handling during cleanup
638+
}
639+
}
640+
641+
expect(cleaned).toBe(1)
642+
expect(lstatSpy).toHaveBeenCalled()
643+
})
644+
})

0 commit comments

Comments
 (0)