Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/core/src/util/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export namespace Glob {
include?: "file" | "all"
dot?: boolean
symlink?: boolean
ignore?: string[]
}

function toGlobOptions(options: Options): GlobOptions {
Expand All @@ -17,6 +18,7 @@ export namespace Glob {
dot: options.dot,
follow: options.symlink ?? false,
nodir: options.include !== "all",
ignore: options.ignore,
}
}

Expand Down
26 changes: 25 additions & 1 deletion packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { realpath } from "fs/promises"
import path from "path"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
Expand All @@ -23,6 +24,8 @@ const AGENTS_EXTERNAL_DIR = ".agents"
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
// Trailing /** makes glob skip the directory instead of stat-ing every file inside it.
const SKIP = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.cache/**"]

// Built-in skill that ships with opencode. The model's intuition for what an
// opencode.json should look like is often wrong, and opencode hard-fails on
Expand Down Expand Up @@ -92,6 +95,7 @@ type DiscoveryState = {
type ScanState = {
matches: Set<string>
dirs: Set<string>
seen: Set<string>
}

export interface Interface {
Expand Down Expand Up @@ -139,12 +143,28 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev
}
})

const canonical = (input: string) =>
Effect.tryPromise({
try: () => realpath(input),
catch: (cause) => cause,
}).pipe(Effect.catch(() => Effect.succeed(input)))

const scan = Effect.fnUntraced(function* (
state: ScanState,
root: string,
pattern: string,
opts?: { dot?: boolean; scope?: string },
) {
const real = yield* canonical(root)
if (state.seen.has(real)) return
const realSkills = yield* Effect.tryPromise({
try: () => realpath(path.join(root, "skills")),
catch: (cause) => cause,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (pattern === EXTERNAL_SKILL_PATTERN && realSkills && state.seen.has(realSkills)) return
state.seen.add(real)
if (pattern === EXTERNAL_SKILL_PATTERN && realSkills) state.seen.add(realSkills)

const matches = yield* Effect.tryPromise({
try: () =>
Glob.scan(pattern, {
Expand All @@ -153,6 +173,7 @@ const scan = Effect.fnUntraced(function* (
include: "file",
symlink: true,
dot: opts?.dot,
ignore: SKIP,
}),
catch: (error) => error,
}).pipe(
Expand All @@ -165,6 +186,9 @@ const scan = Effect.fnUntraced(function* (
)

for (const match of matches) {
const file = yield* canonical(match)
if (state.seen.has(file)) continue
state.seen.add(file)
state.matches.add(match)
state.dirs.add(path.dirname(match))
}
Expand All @@ -180,7 +204,7 @@ const discoverSkills = Effect.fnUntraced(function* (
directory: string,
worktree: string,
) {
const state: ScanState = { matches: new Set(), dirs: new Set() }
const state: ScanState = { matches: new Set(), dirs: new Set(), seen: new Set() }

const externalDirs: string[] = []
if (!disableExternalSkills) {
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,45 @@ description: A skill in the .opencode/skills directory.
{ git: true },
),
)

it.live("skips dependency directories and a skills symlink", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const skillFile = (name: string, description: string) => `---
name: ${name}
description: ${description}
---

# ${name}
`
yield* Effect.promise(async () => {
await Bun.write(
path.join(dir, ".agents", "skills", "kept", "SKILL.md"),
skillFile("kept", "A skill that should load."),
)
await Bun.write(
path.join(dir, ".agents", "skills", "kept", "nested", "SKILL.md"),
skillFile("nested", "A nested skill that should load."),
)
await Bun.write(
path.join(dir, ".agents", "skills", "kept", "node_modules", "pkg", "SKILL.md"),
skillFile("vendored", "A dependency skill that should not load."),
)
await Bun.write(
path.join(dir, ".agents", "skills", "kept", ".git", "SKILL.md"),
skillFile("git-skill", "A git skill that should not load."),
)
await fs.mkdir(path.join(dir, ".claude"), { recursive: true })
await fs.symlink(path.join(dir, ".agents", "skills"), path.join(dir, ".claude", "skills"))
})

const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((item) => item.location !== "<built-in>")
expect(list.map((item) => item.name).toSorted()).toEqual(["kept", "nested"])
expect((yield* skill.dirs()).length).toBe(2)
}),
{ git: true },
),
)
})
28 changes: 28 additions & 0 deletions packages/opencode/test/util/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ describe("Glob", () => {
expect(results.sort()).toEqual([".hidden", "visible"])
})

test("does not descend into ignored directories", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "ok", "nested"), { recursive: true })
await fs.mkdir(path.join(tmp.path, "ok", "node_modules", "pkg"), { recursive: true })
await fs.mkdir(path.join(tmp.path, "ok", ".git"), { recursive: true })
await fs.mkdir(path.join(tmp.path, "ok", "dist"), { recursive: true })
await fs.mkdir(path.join(tmp.path, "ok", ".cache"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "ok", "SKILL.md"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "ok", "nested", "SKILL.md"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "ok", "node_modules", "pkg", "SKILL.md"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "ok", ".git", "SKILL.md"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "ok", "dist", "SKILL.md"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "ok", ".cache", "SKILL.md"), "", "utf-8")

const results = await Glob.scan("**/SKILL.md", {
cwd: tmp.path,
absolute: true,
include: "file",
symlink: true,
dot: true,
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.cache/**"],
})

expect(results.sort()).toEqual(
[path.join(tmp.path, "ok", "SKILL.md"), path.join(tmp.path, "ok", "nested", "SKILL.md")].sort(),
)
})

test("excludes dotfiles when dot option is false", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
Expand Down
Loading