diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts index febf062daa47..cb691b58fdb9 100644 --- a/packages/core/src/util/glob.ts +++ b/packages/core/src/util/glob.ts @@ -8,6 +8,7 @@ export namespace Glob { include?: "file" | "all" dot?: boolean symlink?: boolean + ignore?: string[] } function toGlobOptions(options: Options): GlobOptions { @@ -17,6 +18,7 @@ export namespace Glob { dot: options.dot, follow: options.symlink ?? false, nodir: options.include !== "all", + ignore: options.ignore, } } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..c52c07d6abd0 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -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" @@ -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 @@ -92,6 +95,7 @@ type DiscoveryState = { type ScanState = { matches: Set dirs: Set + seen: Set } export interface Interface { @@ -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, { @@ -153,6 +173,7 @@ const scan = Effect.fnUntraced(function* ( include: "file", symlink: true, dot: opts?.dot, + ignore: SKIP, }), catch: (error) => error, }).pipe( @@ -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)) } @@ -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) { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f365b63f0d72..8751dbdefdf1 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -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 !== "") + expect(list.map((item) => item.name).toSorted()).toEqual(["kept", "nested"]) + expect((yield* skill.dirs()).length).toBe(2) + }), + { git: true }, + ), + ) }) diff --git a/packages/opencode/test/util/glob.test.ts b/packages/opencode/test/util/glob.test.ts index 4ed2f71f3928..55caab51dd5a 100644 --- a/packages/opencode/test/util/glob.test.ts +++ b/packages/opencode/test/util/glob.test.ts @@ -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")