Compare commits

...
Author SHA1 Message Date
Aiden Cline 0bd618be31 refactor(core): share ignored directories across watches and skill samples 2026-10-15 00:11:10 -05:00
29b238d172 fix(core): bound skill discovery and watches
Replace the unbounded `{*.md,**/SKILL.md}` glob with a breadth-first skill
scan. A directory containing SKILL.md is one skill and is not searched
further, hidden entries and node_modules are skipped, and the scan stops six
directories below the source root.

Recursive skill watches ignore hidden entries and node_modules. A skill file
that resolves outside the watched sources gets a file watch instead of a
recursive watch on its parent, which could be all of /nix/store.

Co-authored-by: Dante <duanjl.china@gmail.com>
Co-authored-by: Kugesh Rajasekaran <47845948+kugesh-Rajasekaran@users.noreply.github.com>
2026-10-15 00:02:53 -05:00
11 changed files with 89 additions and 25 deletions

No files matched your search

@@ -8,6 +8,7 @@ import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Skill } from "../../skill.js"
import { SkillFile } from "./skill-file.js"
import { SkillScan } from "./skill-scan.js"
export const Plugin = define({
id: "opencode.config.compatibility",
@@ -24,7 +25,9 @@ export const Plugin = define({
target: string,
type: "file" | "directory",
) {
const updates = yield* watcher.subscribe({ path: target, type })
const updates = yield* watcher.subscribe(
type === "directory" ? { path: target, type, ignore: SkillScan.ignore } : { path: target, type },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
@@ -43,10 +46,7 @@ export const Plugin = define({
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
if (!resolved) continue
yield* watch(resolved, "directory")
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: resolved, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
for (const filepath of files.toSorted()) {
for (const filepath of yield* SkillScan.scan(resolved)) {
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(resolved, filepath, content)
@@ -0,0 +1,46 @@
export * as SkillScan from "./skill-scan.js"
import { FSUtil } from "@opencode/util/fs-util"
import path from "path"
import { Effect } from "effect"
const MAX_DEPTH = 6
export const ignore = [...FSUtil.ignoreGlobs, "**/.*", "**/.*/**"]
export const scan = Effect.fn("SkillScan.scan")(function* (root: string) {
const fs = yield* FSUtil.Service
const files: string[] = []
const queue = [{ directory: root, depth: 0 }]
for (const item of queue) {
const entries = yield* fs.readDirectoryEntries(item.directory).pipe(Effect.orElseSucceed(() => []))
const children = yield* Effect.forEach(
entries.filter((entry) => !entry.name.startsWith(".") && !FSUtil.ignoredDirectories.includes(entry.name)),
(entry) => {
const target = path.join(item.directory, entry.name)
if (entry.type !== "symlink") return Effect.succeed({ name: entry.name, path: target, type: entry.type })
return fs.stat(target).pipe(
Effect.map((info) => (info.type === "Directory" ? "directory" : info.type === "File" ? "file" : "other")),
Effect.orElseSucceed(() => "other" as const),
Effect.map((type) => ({ name: entry.name, path: target, type })),
)
},
)
const skill = children.find((child) => child.type === "file" && child.name === "SKILL.md")
if (skill) {
files.push(skill.path)
continue
}
if (item.depth === 0)
files.push(
...children.filter((child) => child.type === "file" && child.name.endsWith(".md")).map((child) => child.path),
)
if (item.depth < MAX_DEPTH)
queue.push(
...children
.filter((child) => child.type === "directory")
.map((child) => ({ directory: child.path, depth: item.depth + 1 })),
)
}
return files.toSorted()
})
+6 -6
View File
@@ -13,6 +13,7 @@ import { AbsolutePath } from "../../schema.js"
import { Skill } from "../../skill.js"
import { SkillDiscovery } from "../../skill/discovery.js"
import { SkillFile } from "./skill-file.js"
import { SkillScan } from "./skill-scan.js"
type Source = Skill.DirectorySource | Skill.UrlSource
@@ -35,7 +36,9 @@ export const Plugin = define({
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
const updates = yield* watcher.subscribe(
type === "directory" ? { path: target, type, ignore: SkillScan.ignore } : { path: target, type },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
@@ -117,12 +120,9 @@ export const Plugin = define({
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
for (const filepath of files.toSorted()) {
for (const filepath of yield* SkillScan.scan(directory)) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(resolved, "file")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
+1 -1
View File
@@ -29,7 +29,7 @@ export function plan(sources: ConfigDiscovery.Sources) {
...directories.map((path) => ({
path,
type: "directory" as const,
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
ignore: FSUtil.ignoreGlobs,
})),
...Array.from(parents, ([parent, files]) => ({
path: parent,
+8 -2
View File
@@ -1,7 +1,7 @@
export * as Skill from "./skill.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import type { FSUtil } from "@opencode/util/fs-util"
import { FSUtil } from "@opencode/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode/schema/skill"
@@ -56,7 +56,13 @@ export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interfac
const directory = path.dirname(skill.path)
const files =
path.basename(skill.path) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
? (yield* fs.scan("**/*", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
ignore: FSUtil.ignoreGlobs,
}))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, 10)
+2 -1
View File
@@ -16,6 +16,7 @@ import { ConfigPermissionV1 } from "@opencode/core/v1/config/permission"
import { ConfigNormalize } from "@opencode/core/config/normalize"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Bus } from "@opencode/core/bus"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Location } from "@opencode/core/location"
import { Project } from "@opencode/core/project"
@@ -1225,7 +1226,7 @@ describe("Config", () => {
{
type: "directory",
path: AbsolutePath.make(path.join(tmp.path, "global")),
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
ignore: FSUtil.ignoreGlobs.toSorted(),
},
{
type: "entries",
+7 -6
View File
@@ -7,6 +7,7 @@ import { Directory, Document, type Entry, Info } from "@opencode/schema/config"
import { ConfigSkillPlugin } from "@opencode/core/config/plugin/skill"
import { ConfigCompatibilityPlugin } from "@opencode/core/config/plugin/compatibility"
import { SkillFile } from "@opencode/core/config/plugin/skill-file"
import { SkillScan } from "@opencode/core/config/plugin/skill-scan"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Bus } from "@opencode/core/bus"
@@ -205,7 +206,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
{ claude: [AbsolutePath.make(claude)], agents: [AbsolutePath.make(agents)] },
)
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory", ignore: SkillScan.ignore.toSorted() })))
}),
),
),
@@ -335,7 +336,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
)
it.live("watches canonical directories behind symlinked skills", () =>
it.live("watches canonical files behind symlinked skills", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
@@ -352,7 +353,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
expect(yield* watcher.subscriptions()).toContainEqual({ path: file, type: "file" })
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
@@ -381,7 +382,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: first, type: "directory", ignore: SkillScan.ignore.toSorted() },
{ path: source, type: "file" },
])
@@ -393,9 +394,9 @@ describe("ConfigSkillPlugin.Plugin", () => {
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: first, type: "directory", ignore: SkillScan.ignore.toSorted() },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: second, type: "directory", ignore: SkillScan.ignore.toSorted() },
{ path: source, type: "file" },
])
}),
+3 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
import type { ConfigDiscovery } from "@opencode/core/config/discovery"
import { ConfigWatch } from "@opencode/core/config/watch"
import { AbsolutePath } from "@opencode/core/schema"
import { FSUtil } from "@opencode/util/fs-util"
const project = path.resolve("watch-plan-project")
const root = AbsolutePath.make(path.join(project, ".opencode"))
@@ -26,8 +27,8 @@ describe("ConfigWatch.plan", () => {
project: [{ path: root, present: true }],
})
expect(Array.from(present.values())).toEqual([
{ path: system, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
{ path: system, type: "directory", ignore: FSUtil.ignoreGlobs },
{ path: root, type: "directory", ignore: FSUtil.ignoreGlobs },
{
path: project,
type: "entries",
+4
View File
@@ -273,6 +273,10 @@ export namespace FSUtil {
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}
export const ignoredDirectories = ["node_modules", ".git", ".venv", "venv", "__pycache__"]
export const ignoreGlobs = [...ignoredDirectories, `**/{${ignoredDirectories.join(",")}}/**`]
export function overlaps(a: string, b: string) {
return contains(a, b) || contains(b, a)
}
+2
View File
@@ -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,
}
}
+5 -2
View File
@@ -59,7 +59,10 @@ skills/
```
- Markdown files must be at the source root, such as `skills/review.md`.
- Files named exactly `SKILL.md` can be at any depth, such as `skills/git-release/SKILL.md`.
- Files named exactly `SKILL.md` can be nested in grouping directories, such as `skills/git-release/SKILL.md` or `skills/teams/release/SKILL.md`.
- A directory containing `SKILL.md` is one skill. OpenCode does not search inside it for more skills.
- Hidden files and directories and `node_modules` are skipped.
- Search stops six directories below the source root.
## Sources
@@ -110,7 +113,7 @@ For that entry, OpenCode downloads each file from `<base-url>/git-release/<file>
| Entry file | Include `SKILL.md` or `<name>.md`, such as `git-release.md` |
| Updates | Increment `version` when files change so OpenCode refreshes its cache |
Prefer the named Markdown form in an HTTP catalog. Each downloaded skill directory becomes a source root, so `git-release.md` has the ID `git-release`. A root-level `SKILL.md` currently has the literal ID `SKILL` in V2.
Each downloaded skill directory becomes a source root named after the entry, so both `git-release.md` and `SKILL.md` have the ID `git-release`.
## Frontmatter