mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(core): detect new ecosystem config roots (#47026)
This commit is contained in:
committed by
GitHub
Unverified
parent
887f319769
commit
59b29de409
@@ -184,6 +184,8 @@ export const layer = (options?: Options) =>
|
||||
})
|
||||
|
||||
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
|
||||
const claude = yield* Effect.filter(sources.claude, (path) => fs.isDir(path))
|
||||
const agents = yield* Effect.filter(sources.agents, (path) => fs.isDir(path))
|
||||
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
|
||||
@@ -221,8 +223,8 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...sources.claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
|
||||
...sources.agents.map((path) => new AgentsDirectory({ type: "agents", path })),
|
||||
...claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
|
||||
...agents.map((path) => new AgentsDirectory({ type: "agents", path })),
|
||||
...globalSupplementary,
|
||||
...explicit,
|
||||
...direct,
|
||||
|
||||
@@ -38,8 +38,7 @@ export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options
|
||||
Effect.gen(function* () {
|
||||
// Resolve the parent too: missing children must honor symlinked global roots.
|
||||
const parent = yield* fs.resolve(directory)
|
||||
const ecosystem = yield* Effect.filter([".claude", ".agents"], (name) => fs.exists(path.join(directory, name)))
|
||||
return yield* Effect.forEach([...ecosystem, ".opencode", ...names.toReversed()], (name) =>
|
||||
return yield* Effect.forEach([".claude", ".agents", ".opencode", ...names.toReversed()], (name) =>
|
||||
fs
|
||||
.resolve(path.join(parent, name))
|
||||
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
|
||||
@@ -71,13 +70,13 @@ export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options
|
||||
),
|
||||
claude: [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...(globalEnabled ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
],
|
||||
agents: [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...(globalEnabled ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -13,6 +13,8 @@ export function plan(sources: ConfigDiscovery.Sources) {
|
||||
const files = [
|
||||
...sources.direct,
|
||||
...sources.project.map((root) => root.path),
|
||||
...sources.claude,
|
||||
...sources.agents,
|
||||
...(sources.explicit ? [sources.explicit] : []),
|
||||
]
|
||||
// Keep a parent watch for each root so deletion/recreation is observable.
|
||||
|
||||
@@ -105,6 +105,10 @@ describe("Config", () => {
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(ambient(yield* config.entries())).toEqual([])
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(
|
||||
(yield* watcher.subscriptions()).filter((watch) => watch.type === "entries" && watch.path === home),
|
||||
).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
|
||||
@@ -912,7 +916,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
it.live("does not recursively watch ecosystem config roots", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -933,7 +937,11 @@ describe("Config", () => {
|
||||
path: AbsolutePath.make(path.join(tmp.path, "global")),
|
||||
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
|
||||
},
|
||||
{ type: "entries", path: tmp.path, names: [".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
{
|
||||
type: "entries",
|
||||
path: tmp.path,
|
||||
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
|
||||
}),
|
||||
@@ -1491,7 +1499,7 @@ describe("Config", () => {
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({
|
||||
path: tmp.path,
|
||||
type: "entries",
|
||||
names: [".opencode", "opencode.json", "opencode.jsonc"],
|
||||
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,54 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
for (const input of [
|
||||
{ root: ".agents", global: false },
|
||||
{ root: "../.claude", global: false },
|
||||
{ root: "home/.agents", global: true },
|
||||
{ root: "home/.claude", global: true },
|
||||
]) {
|
||||
it.live(`loads skills when ${input.root} appears after startup`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const root = path.resolve(project, input.root)
|
||||
const file = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(project, "home"), { recursive: true })
|
||||
await fs.mkdir(path.join(project, "global"))
|
||||
await Bun.write(file, JSON.stringify({ shell: "initial" }))
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const skills = yield* Skill.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigSkillPlugin.Plugin.effect(host)
|
||||
expect(yield* skills.list()).toEqual([])
|
||||
|
||||
// Finish startup by observing an ordinary config reload before creating the root.
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ shell: "ready" })))
|
||||
yield* waitUntil(
|
||||
config.entries().pipe(Effect.map((entries) => Config.latest(entries, "shell") === "ready")),
|
||||
)
|
||||
const skill = path.join(root, "skills", "probe", "SKILL.md")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(skill, "---\nname: probe\ndescription: Hot reload\n---\nTest skill"),
|
||||
)
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.some((item) => item.id === "probe"))))
|
||||
expect((yield* skills.list())[0]?.location).toBe(AbsolutePath.make(skill))
|
||||
yield* Effect.promise(() => fs.rm(root, { recursive: true }))
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.length === 0)))
|
||||
yield* Effect.promise(() => Bun.write(skill, "---\nname: probe\ndescription: Recreated\n---\nTest skill"))
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items[0]?.description === "Recreated")))
|
||||
}).pipe(Effect.provide(liveConfig(project, undefined, { global: input.global })))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("retains readiness signalled synchronously during initial config startup", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
@@ -270,9 +318,9 @@ describe("config plugin reloads", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function liveConfig(directory: string, native?: Watcher.NativeInterface) {
|
||||
function liveConfig(directory: string, native?: Watcher.NativeInterface, options: Config.Options = { global: false }) {
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
|
||||
Config.node.replace(Config.configured({ global: false })),
|
||||
Config.node.replace(Config.configured(options)),
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("ConfigWatch.plan", () => {
|
||||
test("groups missing candidates and keeps parent watches when roots appear", () => {
|
||||
const missing = ConfigWatch.plan(sources)
|
||||
expect(Array.from(missing.values())).toEqual([
|
||||
{ path: project, type: "entries", names: [".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
{ path: project, type: "entries", names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
])
|
||||
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
|
||||
expect(Array.from(present.values())).toEqual([
|
||||
|
||||
Reference in New Issue
Block a user