Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 12 additions & 39 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin"
import {
access,
constants,
lstat,
readdir,
readlink,
mkdir,
symlink,
unlink,
stat
} from "fs/promises"
import { access, constants, lstat, readdir, mkdir, symlink, unlink, stat } from "fs/promises"
import { join } from "path"
import { homedir } from "os"

Expand Down Expand Up @@ -235,12 +225,13 @@ async function findSkillsInMarketplaces(
async function syncSkills(client: PluginInput["client"]): Promise<void> {
const home = homedir()
const claudeDir = join(home, ".claude")
const opencodeDir = join(home, ".config", "opencode")
const cacheDir = join(claudeDir, "plugins", "cache")
const marketplacesDir = join(claudeDir, "plugins", "marketplaces")
const targetDir = join(claudeDir, "skills")
const targetDir = join(opencodeDir, "skill")

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

// Clean existing symlinks
// Step 1: Clean all existing symlinks (safety-first)
let cleaned = 0
let updated = 0
let created = 0

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

// ONLY remove symlinks, never remove regular files or directories
if (lstats.isSymbolicLink()) {
const target = await readlink(entryPath)
const skill = skillMap.get(entry)

// Remove broken or stale symlinks
const targetExists = await exists(entryPath)
if (!targetExists || !skill) {
await unlink(entryPath)
cleaned++
if (skill) skillMap.delete(entry)
continue
}

// Update if pointing to old version
if (target !== skill.path) {
await unlink(entryPath)
await symlink(skill.path, entryPath)
updated++
}

skillMap.delete(entry)
await unlink(entryPath)
cleaned++
}
} catch {
// Skip problematic entries
}
}
}

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

(client as unknown as { app: { log: (msg: string) => void } }).app.log(
`Synced ${totalFound} skills (limit: ${MAX_SKILLS}): ` +
`${created} created, ${updated} updated, ${cleaned} cleaned`
`${created} created, ${cleaned} cleaned`
)
} catch (err) {
console.error("[claude-skill-sync] Sync failed:", err)
Expand All @@ -343,8 +316,8 @@ async function syncSkills(client: PluginInput["client"]): Promise<void> {
/**
* Claude Skill Sync Plugin
*
* Automatically discovers and syncs OpenCode plugin skills to the Claude Code
* ~/.claude/skills directory via symlinks. Runs asynchronously to avoid blocking
* Automatically discovers and syncs OpenCode plugin skills to the OpenCode
* ~/.config/opencode/skill directory via symlinks. Runs asynchronously to avoid blocking
* OpenCode startup.
*
* @example
Expand Down
192 changes: 189 additions & 3 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,18 @@ describe("mock filesystem operations", () => {
it("should detect symlink with lstat", async () => {
mockFs.mockSymlinks.set("/link", "/source")

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

expect(stats.isSymbolicLink()).toBe(true)
})

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

const stats = await mockFs.mocks.lstat("/dir")
const stats = (await mockFs.mocks.lstat("/dir")) as {
isDirectory: () => boolean
isSymbolicLink: () => boolean
}

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

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

expect(latest).toBe("2.1.3")
})
Expand Down Expand Up @@ -456,3 +459,186 @@ describe("edge cases", () => {
expect(mockFs.mockSymlinks.get("/final")).toBe("/source")
})
})

/**
* Clean slate symlink management tests
*/
describe("symlink cleanup (clean slate)", () => {
let mockFs: ReturnType<typeof createMockFilesystem>

beforeEach(() => {
mockFs = createMockFilesystem()
})

it("should remove all symlinks from target directory", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["skill1", "skill2", "skill3"])

// Add symlinks
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")
mockFs.mockSymlinks.set("/skills/skill2", "/cache/skill2")
mockFs.mockSymlinks.set("/skills/skill3", "/cache/skill3")

// Simulate cleanup: remove all symlinks
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
let cleaned = 0

for (const entry of entries) {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
cleaned++
}
}

expect(cleaned).toBe(3)
expect(mockFs.mockSymlinks.has("/skills/skill1")).toBe(false)
expect(mockFs.mockSymlinks.has("/skills/skill2")).toBe(false)
expect(mockFs.mockSymlinks.has("/skills/skill3")).toBe(false)
})

it("should NOT remove regular files in target directory", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["regular-file.txt"])

// Add a regular file (not a symlink)
mockFs.mockFiles.add("/skills/regular-file.txt")

// Simulate cleanup: only remove symlinks
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
let cleaned = 0

for (const entry of entries) {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
cleaned++
}
}

expect(cleaned).toBe(0)
expect(mockFs.mockFiles.has("/skills/regular-file.txt")).toBe(true)
})

it("should NOT remove directories in target directory", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["subdir"])
addDirStructure(mockFs, "/skills/subdir", [])

// Simulate cleanup: only remove symlinks
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
let cleaned = 0

for (const entry of entries) {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
cleaned++
}
}

expect(cleaned).toBe(0)
expect(mockFs.mockDirs.has("/skills/subdir")).toBe(true)
})

it("should handle mixed symlinks and files", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["skill1", "file.txt", "skill2"])

// Add mixed content
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")
mockFs.mockFiles.add("/skills/file.txt")
mockFs.mockSymlinks.set("/skills/skill2", "/cache/skill2")

// Simulate cleanup: only remove symlinks
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
let cleaned = 0

for (const entry of entries) {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
cleaned++
}
}

expect(cleaned).toBe(2)
expect(mockFs.mockSymlinks.has("/skills/skill1")).toBe(false)
expect(mockFs.mockSymlinks.has("/skills/skill2")).toBe(false)
expect(mockFs.mockFiles.has("/skills/file.txt")).toBe(true)
})

it("should create fresh symlinks for all skills after cleanup", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["old-skill"])

// Old symlink
mockFs.mockSymlinks.set("/skills/old-skill", "/old/cache/skill")

// Simulate cleanup
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
for (const entry of entries) {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
}
}

// Create new symlinks
const skillMap = new Map<string, { path: string }>()
skillMap.set("python-tdd", { path: "/cache/python-tdd" })
skillMap.set("react-web", { path: "/cache/react-web" })

let created = 0
for (const [name, skill] of skillMap) {
const linkPath = join(targetDir, name)
await mockFs.mocks.symlink(skill.path, linkPath)
created++
}

expect(created).toBe(2)
expect(mockFs.mockSymlinks.get("/skills/python-tdd")).toBe("/cache/python-tdd")
expect(mockFs.mockSymlinks.get("/skills/react-web")).toBe("/cache/react-web")
expect(mockFs.mockSymlinks.has("/skills/old-skill")).toBe(false)
})

it("should handle lstat errors gracefully during cleanup", async () => {
const targetDir = "/skills"
addDirStructure(mockFs, targetDir, ["skill1"])
mockFs.mockSymlinks.set("/skills/skill1", "/cache/skill1")

// Spy on lstat to verify error handling
const lstatSpy = vi.spyOn(mockFs.mocks, "lstat")

// Simulate cleanup with error handling
const entries = (await mockFs.mocks.readdir(targetDir)) as string[]
let cleaned = 0

for (const entry of entries) {
try {
const entryPath = join(targetDir, entry)
const lstats = (await mockFs.mocks.lstat(entryPath)) as { isSymbolicLink: () => boolean }

if (lstats.isSymbolicLink()) {
await mockFs.mocks.unlink(entryPath)
cleaned++
}
} catch {
// Error handling during cleanup
}
}

expect(cleaned).toBe(1)
expect(lstatSpy).toHaveBeenCalled()
})
})