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
25 changes: 13 additions & 12 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/** Compatibility roots consumed by internal compatibility plugins. */
/** Compatibility roots consumed by the skill config plugin. */
readonly compatibility?: () => Effect.Effect<{
readonly claude: readonly AbsolutePath[]
readonly agents: readonly AbsolutePath[]
Expand Down Expand Up @@ -237,8 +237,15 @@ export const layer = (options?: Options) =>
]
})

const loadCompatibility = Effect.fnUntraced(function* (sources: ConfigDiscovery.Sources) {
return yield* Effect.all({
claude: Effect.filter(sources.claude, fs.isDir),
agents: Effect.filter(sources.agents, fs.isDir),
})
})

const initial = yield* ConfigDiscovery.discover(options)
let sources = initial
let compatibility = yield* loadCompatibility(initial)
let configs = yield* load(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
const reloads = yield* PubSub.sliding<void>(1)
Expand Down Expand Up @@ -266,12 +273,10 @@ export const layer = (options?: Options) =>
function* () {
const discovered = yield* ConfigDiscovery.discover(options)
const next = yield* load(discovered)
const nextCompatibility = yield* loadCompatibility(discovered)
yield* reconcile(discovered)
const compatibilityChanged =
!isDeepStrictEqual(sources.claude, discovered.claude) ||
!isDeepStrictEqual(sources.agents, discovered.agents)
if (isDeepStrictEqual(configs, next) && !compatibilityChanged) return
sources = discovered
if (isDeepStrictEqual(configs, next) && isDeepStrictEqual(compatibility, nextCompatibility)) return
compatibility = nextCompatibility
configs = next
yield* bus.publish(Event.Updated, {})
},
Expand Down Expand Up @@ -352,11 +357,7 @@ export const layer = (options?: Options) =>
entries: Effect.fnUntraced(function* () {
return configs
}),
compatibility: () =>
Effect.all({
claude: Effect.filter(sources.claude, fs.isDir),
agents: Effect.filter(sources.agents, fs.isDir),
}),
compatibility: () => Effect.succeed(compatibility),
changes: () => Stream.fromPubSub(updates),
update,
})
Expand Down
78 changes: 0 additions & 78 deletions packages/core/src/config/plugin/compatibility.ts

This file was deleted.

20 changes: 17 additions & 3 deletions packages/core/src/config/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ export const Plugin = define({
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
const compatibilityRoots = config.compatibility ?? (() => Effect.succeed({ claude: [], agents: [] }))
const loaded: {
entries: Entry[]
compatibility: { readonly claude: readonly AbsolutePath[]; readonly agents: readonly AbsolutePath[] }
skills: Skill.Info[]
} = {
entries: yield* config.entries(),
compatibility: yield* compatibilityRoots(),
skills: [],
}
const watches = yield* FiberMap.make<string>()
Expand Down Expand Up @@ -82,6 +88,9 @@ export const Plugin = define({
}
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...loaded.compatibility.claude, ...loaded.compatibility.agents]) {
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const directory of directories) {
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
Expand Down Expand Up @@ -181,8 +190,13 @@ export const Plugin = define({
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.all({ entries: config.entries(), compatibility: compatibilityRoots() }).pipe(
Effect.tap((next) =>
Effect.sync(() => {
loaded.entries = next.entries
loaded.compatibility = next.compatibility
}),
),
Effect.andThen(refresh()),
Effect.andThen(ctx.skill.reload()),
),
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigCompatibilityPlugin } from "../config/plugin/compatibility.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
Expand Down Expand Up @@ -237,7 +236,6 @@ const post = [
ConfigShellPlugin.Plugin,
ConfigSnapshotPlugin.Plugin,
ConfigToolOutputPlugin.Plugin,
ConfigCompatibilityPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
Expand Down
28 changes: 28 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,34 @@ describe("Config", () => {
),
)

it.live("publishes config updates when compatibility roots appear", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const root = path.join(project, ".claude")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Test
expect((yield* config.compatibility!()).claude).not.toContain(AbsolutePath.make(root))
const changed = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))

yield* Effect.promise(() => fs.mkdir(root))
yield* watcher.emit({ type: "create", path: root })

yield* Fiber.join(changed).pipe(Effect.timeout("2 seconds"))
expect((yield* config.compatibility!()).claude).toContain(AbsolutePath.make(root))
}).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
}),
),
),
)

// Real watcher on purpose: the regression this pins (a deleted config file's
// watch being torn down, making recreation invisible) only reproduces with
// path-faithful event delivery.
Expand Down
24 changes: 11 additions & 13 deletions packages/core/test/config/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode/core/config"
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 { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
Expand Down Expand Up @@ -61,12 +60,7 @@ const startEntries = Effect.fnUntraced(function* (
reload: service.reload,
},
})
yield* ConfigCompatibilityPlugin.Plugin.effect(pluginHost).pipe(
Effect.provide(Config.testLayer(entries, compatibility)),
)
yield* ConfigSkillPlugin.Plugin.effect(
pluginHost,
).pipe(
yield* ConfigSkillPlugin.Plugin.effect(pluginHost).pipe(
Effect.provide(Config.testLayer(entries, compatibility)),
Effect.provideService(SkillDiscovery.Service, discovery),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
Expand Down Expand Up @@ -391,18 +385,22 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
)

it.live("follows missing source directories as their parents appear", () =>
it.live("follows missing compatibility skill directories as their parents appear", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const skill = yield* start([source], tmp.path)
const root = path.join(tmp.path, "generated")
const source = path.join(root, "skills")
const skill = yield* startEntries([], tmp.path, tmp.path, emptyDiscovery, {
claude: [AbsolutePath.make(root)],
agents: [],
})
const watcher = yield* Watcher.Test
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
expect(yield* watcher.subscriptions()).toEqual([{ path: root, type: "file" }])

yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
yield* Effect.promise(() => fs.mkdir(root))
yield* emitAndWait({ type: "create", path: root })
yield* Effect.promise(async () => {
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
await write(source, "deploy", "Deploy")
Expand Down
Loading