Skip to content
91 changes: 75 additions & 16 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
Expand All @@ -12,6 +6,13 @@ import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { Effect, Layer, ServiceMap } from "effect"
import { readdir, realpath, stat } from "fs/promises"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
Expand Down Expand Up @@ -102,18 +103,76 @@ export namespace Skill {
}

const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
let roots = [root]

if (pattern === EXTERNAL_SKILL_PATTERN) {
roots = [path.join(root, "skills")]
}

if (pattern === OPENCODE_SKILL_PATTERN) {
roots = [path.join(root, "skill"), path.join(root, "skills")]
}

// Prevent symlink loops by tracking visited real directories.
const seenDirs = new Set<string>()

const walk = async (dir: string, relativePath: string) => {
const resolvedDir = await realpath(dir).catch(() => {
// Fall back to the original path if the directory disappears mid-scan.
return dir
})
if (seenDirs.has(resolvedDir)) return
seenDirs.add(resolvedDir)

const dirEntries = await readdir(dir, { withFileTypes: true })
for (const entry of dirEntries) {
// Skip hidden paths
if (!opts?.dot && entry.name.startsWith(".")) continue

const absoluteEntryPath = path.join(dir, entry.name)
const relativeEntryPath = relativePath ? path.join(relativePath, entry.name) : entry.name

// Match regular files directly against the skill glob
if (entry.isFile()) {
if (Glob.match(SKILL_PATTERN, relativeEntryPath.replaceAll(path.sep, "/"))) await add(state, absoluteEntryPath)
continue
}

// Recurse into subdirs
if (entry.isDirectory()) {
await walk(absoluteEntryPath, relativeEntryPath)
continue
}

// Symlinks need a second check so we can follow skill roots without
// blindly recursing into link cycles.
if (!entry.isSymbolicLink()) continue

// Preserve symlinked skill roots and files, but decide based on the
// target type so symlink cycles are still stopped by the realpath set.
const targetStat = await stat(absoluteEntryPath).catch(() => undefined)
if (!targetStat) continue

// Add symlinked skill files
if (targetStat.isFile()) {
if (Glob.match(SKILL_PATTERN, relativeEntryPath.replaceAll(path.sep, "/"))) await add(state, absoluteEntryPath)
continue
}

// Recurse into unseen symlinked subdirs
if (targetStat.isDirectory()) {
await walk(absoluteEntryPath, relativeEntryPath)
}
}
}

for (const dir of roots) {
if (!(await Filesystem.isDir(dir))) continue
await walk(dir, "").catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
log.error(`failed to scan ${opts.scope} skills`, { dir, error })
})
}
}

// TODO: Migrate to Effect
Expand Down
43 changes: 43 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,49 @@ This skill is loaded from the global home directory.
}
})

test("follows symlinked .agents skills without recursing through symlink loops", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const root = path.join(dir, ".codex", "skills")
const skill = path.join(root, "linked-skill")
const loop = path.join(root, ".mise", "state", "trusted-configs")

await fs.mkdir(skill, { recursive: true })
await fs.mkdir(path.join(dir, ".agents"), { recursive: true })
await fs.mkdir(loop, { recursive: true })

await Bun.write(
path.join(skill, "SKILL.md"),
`---
name: linked-skill
description: A skill discovered through a symlinked .agents root.
---

# Linked Skill
`,
)

await fs.symlink(path.join(dir, ".codex", "skills"), path.join(dir, ".agents", "skills"))
await fs.symlink(dir, path.join(loop, "home"))
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = (await Promise.race([
Skill.all(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("skill scan timed out")), 1500)),
])) as Awaited<ReturnType<typeof Skill.all>>

expect(skills.length).toBe(1)
expect(skills[0].name).toBe("linked-skill")
expect(skills[0].location).toContain(path.join(".agents", "skills", "linked-skill", "SKILL.md"))
},
})
})

test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
await using tmp = await tmpdir({
git: true,
Expand Down
Loading