diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a56c1119f91b..45791ee6be86 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -49,6 +49,8 @@ export const Options = Schema.Struct({ global: Schema.optional(Schema.Boolean), file: Schema.optional(Schema.String), content: Schema.optional(Schema.String), + // Defaults to OPENCODE_DISABLE_CLAUDE_CODE when unset. + disableClaudeCode: Schema.optional(Schema.Boolean), }) export type Options = typeof Options.Type diff --git a/packages/core/src/config/discovery.ts b/packages/core/src/config/discovery.ts index 71c0c03d9b79..78f041a3f532 100644 --- a/packages/core/src/config/discovery.ts +++ b/packages/core/src/config/discovery.ts @@ -1,7 +1,7 @@ export * as ConfigDiscovery from "./discovery.js" import path from "path" -import { Effect } from "effect" +import { Config, Effect } from "effect" import { FSUtil } from "@opencode/util/fs-util" import { Global } from "@opencode/util/global" import { Location } from "../location.js" @@ -50,6 +50,8 @@ export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options ) const globalEnabled = options?.global !== false + const disableClaudeCode = + options?.disableClaudeCode ?? (yield* Config.boolean("OPENCODE_DISABLE_CLAUDE_CODE").pipe(Config.withDefault(false))) const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name))) // Global sources must not re-enter through the project walk. const visible = discovered @@ -60,6 +62,18 @@ export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options ) .map(({ item }) => item) + // Optional roots vanish when disabled; their global directory joins only + // while the global scope is enabled. + const optionalRoots = (name: string, globalPath: AbsolutePath, enabled = true) => + enabled + ? [ + ...new Set([ + ...(globalEnabled ? [globalPath] : []), + ...visible.filter((item) => path.basename(item) === name).toReversed(), + ]), + ] + : [] + return { global: globalEnabled ? globalDirectory : undefined, explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined, @@ -68,17 +82,7 @@ export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options visible.filter((item) => path.basename(item) === ".opencode").toReversed(), (directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))), ), - claude: [ - ...new Set([ - ...(globalEnabled ? [globalClaudeDirectory] : []), - ...visible.filter((item) => path.basename(item) === ".claude").toReversed(), - ]), - ], - agents: [ - ...new Set([ - ...(globalEnabled ? [globalAgentsDirectory] : []), - ...visible.filter((item) => path.basename(item) === ".agents").toReversed(), - ]), - ], + claude: optionalRoots(".claude", globalClaudeDirectory, !disableClaudeCode), + agents: optionalRoots(".agents", globalAgentsDirectory), } satisfies Sources }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c3deb28d364f..cc2eb173c83c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -2,6 +2,7 @@ import path from "path" import fs from "fs/promises" import { describe, expect, test } from "bun:test" import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect" +import { fromEnv, layer } from "effect/ConfigProvider" import { FastCheck } from "effect/testing" import { Config } from "@opencode/core/config" import { Directory, Document, Event, Info } from "@opencode/schema/config" @@ -45,6 +46,7 @@ function testLayer( credentialNode = emptyCredentialNode, wellknownNode = emptyWellknownNode, options?: Config.Options, + env: Record = {}, ) { const locationLayer = Layer.succeed( Location.Service, @@ -63,9 +65,10 @@ function testLayer( WellKnown.node.replace(wellknownNode), Watcher.node.replace(watcher), ]) - // Merge the watcher layer by reference so Watcher.Test resolves to the same - // memoized instance the built graph uses. - return Layer.mergeAll(built, watcher) + // Pin the config environment (empty by default) so discovery never reads the + // ambient process environment. Merge the watcher layer by reference so + // Watcher.Test resolves to the same memoized instance the built graph uses. + return Layer.mergeAll(built.pipe(Layer.provide(layer(fromEnv({ env })))), watcher) } const provider = { @@ -1608,4 +1611,111 @@ describe("Config", () => { }), ), ) + + it.live("excludes claude sources when disableClaudeCode option is set", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const directory = path.join(tmp.path, "repo") + const globalAgents = path.join(global, "home", ".agents") + const globalClaude = path.join(global, "home", ".claude") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(globalAgents, { recursive: true }) + await fs.mkdir(globalClaude, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(directory, ".agents"), { recursive: true }) + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const compatibility = yield* config.compatibility!() + expect(compatibility.claude.filter((item) => inFixture(tmp.path, item))).toEqual([]) + expect(compatibility.agents.filter((item) => inFixture(tmp.path, item))).toEqual([ + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + ]) + }).pipe( + Effect.provide( + testLayer(directory, global, directory, undefined, Watcher.testLayer, emptyCredentialNode, emptyWellknownNode, { + disableClaudeCode: true, + }), + ), + ) + }) + }), + ), + ) + + it.live("excludes claude sources when OPENCODE_DISABLE_CLAUDE_CODE is set", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const directory = path.join(tmp.path, "repo") + const globalAgents = path.join(global, "home", ".agents") + const globalClaude = path.join(global, "home", ".claude") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(globalAgents, { recursive: true }) + await fs.mkdir(globalClaude, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(directory, ".agents"), { recursive: true }) + }) + + yield* Effect.gen(function* () { + const config = yield* Config.Service + const compatibility = yield* config.compatibility!() + expect(compatibility.claude.filter((item) => inFixture(tmp.path, item))).toEqual([]) + expect(compatibility.agents.filter((item) => inFixture(tmp.path, item))).toEqual([ + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + ]) + }).pipe( + Effect.provide( + testLayer( + directory, + global, + directory, + undefined, + Watcher.testLayer, + emptyCredentialNode, + emptyWellknownNode, + undefined, + { OPENCODE_DISABLE_CLAUDE_CODE: "1" }, + ), + ), + ) + + // Proves the empty claude list above comes from the flag, not a broken fixture. + yield* Effect.gen(function* () { + const config = yield* Config.Service + const compatibility = yield* config.compatibility!() + expect(compatibility.claude.filter((item) => inFixture(tmp.path, item))).toEqual([ + AbsolutePath.make(globalClaude), + ]) + }).pipe( + Effect.provide( + testLayer( + directory, + global, + directory, + undefined, + Watcher.testLayer, + emptyCredentialNode, + emptyWellknownNode, + ), + ), + ) + }) + }), + ), + ) })