From 59b29de40966803e2c7cd734d439843fb773f6a6 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 3 Sep 2026 16:09:58 +0530 Subject: [PATCH] fix(core): detect new ecosystem config roots (#47026) --- packages/core/src/config.ts | 6 ++- packages/core/src/config/discovery.ts | 7 ++-- packages/core/src/config/watch.ts | 2 + packages/core/test/config/config.test.ts | 14 +++++-- packages/core/test/config/reload.test.ts | 52 +++++++++++++++++++++++- packages/core/test/config/watch.test.ts | 2 +- 6 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b947519671e..fb4220f1747 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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, diff --git a/packages/core/src/config/discovery.ts b/packages/core/src/config/discovery.ts index ad21f5d7da5..8214c481bf1 100644 --- a/packages/core/src/config/discovery.ts +++ b/packages/core/src/config/discovery.ts @@ -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(), ]), ], diff --git a/packages/core/src/config/watch.ts b/packages/core/src/config/watch.ts index 9a99e175171..e511fbec1cc 100644 --- a/packages/core/src/config/watch.ts +++ b/packages/core/src/config/watch.ts @@ -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. diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 88c37be0387..68b4a27824e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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))) }), diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 32bbac5c036..5f22a8e80bf 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -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) }))), ), diff --git a/packages/core/test/config/watch.test.ts b/packages/core/test/config/watch.test.ts index b3834520d8c..7fb3d06f074 100644 --- a/packages/core/test/config/watch.test.ts +++ b/packages/core/test/config/watch.test.ts @@ -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([