diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8dc8c6ee54a8..643093885db6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -316,6 +316,13 @@ export type Info = DeepMutable> & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] + instruction_origins?: InstructionOrigin[] +} + +export type InstructionOrigin = { + spec: string + source: string + scope: ConfigPlugin.Scope } type State = { @@ -365,7 +372,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string } function writable(info: Info) { - const { plugin_origins: _plugin_origins, ...next } = info + const { plugin_origins: _plugin_origins, instruction_origins: _instruction_origins, ...next } = info return next } @@ -548,9 +555,33 @@ export const layer = Layer.effect( result.plugin_origins = plugins }) + const mergeInstructionOrigins = Effect.fnUntraced(function* ( + source: string, + list: string[] | undefined, + kind?: ConfigPlugin.Scope, + ) { + if (!list?.length) return + const hit = kind ?? (yield* pluginScopeForSource(source)) + const merged = [...(result.instruction_origins ?? []), ...list.map((spec) => ({ spec, source, scope: hit }))] + const seen = new Set() + result.instruction_origins = merged + .toReversed() + .filter((item) => { + if (seen.has(item.spec)) return false + seen.add(item.spec) + return true + }) + .toReversed() + }) + const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { result = mergeConfigConcatArrays(result, next) - return mergePluginOrigins(source, next.plugin, kind) + return Effect.all( + [mergePluginOrigins(source, next.plugin, kind), mergeInstructionOrigins(source, next.instructions, kind)], + { + discard: true, + }, + ) } for (const [key, value] of Object.entries(auth)) { @@ -727,12 +758,13 @@ export const layer = Layer.effect( // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { - result = mergeConfigConcatArrays( - result, + yield* merge( + managed.source, yield* loadConfig(managed.text, { dir: path.dirname(managed.source), source: managed.source, }), + "global", ) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index cae261e72b23..b2610d0106bc 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -74,16 +74,21 @@ export const layer: Layer.Layer< ), ) - const relative = Effect.fnUntraced(function* (instruction: string) { + const relative = Effect.fnUntraced(function* (instruction: string, origin?: Config.InstructionOrigin) { const ctx = yield* InstanceState.context if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { return yield* fs .globUp(instruction, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) } - return yield* fs - .globUp(instruction, global.config, global.config) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const src = origin?.source + const [base, stop] = (() => { + if (src === "OPENCODE_CONFIG_CONTENT") return [ctx.directory, ctx.worktree] as const + if (!src || src.startsWith("http://") || src.startsWith("https://")) return [global.config, global.config] as const + if (path.resolve(src) === path.resolve(global.config)) return [global.config, global.config] as const + return [path.dirname(path.resolve(src)), ctx.worktree] as const + })() + return yield* fs.globUp(instruction, base, stop).pipe(Effect.catch(() => Effect.succeed([] as string[]))) }) const read = Effect.fnUntraced(function* (filepath: string) { @@ -134,6 +139,7 @@ export const layer: Layer.Layer< for (const raw of config.instructions) { if (raw.startsWith("https://") || raw.startsWith("http://")) continue const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw + const origin = config.instruction_origins?.find((item) => item.spec === raw) const matches = yield* ( path.isAbsolute(instruction) ? fs.glob(path.basename(instruction), { @@ -141,7 +147,7 @@ export const layer: Layer.Layer< absolute: true, include: "file", }) - : relative(instruction) + : relative(instruction, origin) ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) matches.forEach((item) => paths.add(path.resolve(item))) } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 85cb78a329c6..ad9caa010562 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1040,6 +1040,39 @@ it.effect("deduplicates duplicate instructions from global and local configs", ( ), ) +it.effect("tracks instruction origins by config source", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const global = yield* tmpdirScoped() + const directory = path.join(root, "project") + const local = path.join(directory, ".opencode") + + yield* Effect.all( + [ + writeConfigEffect(global, schemaConfig({ instructions: ["global.md"] })), + writeConfigEffect(directory, schemaConfig({ instructions: ["project.md"] })), + writeConfigEffect(local, schemaConfig({ instructions: ["local.md"] })), + ], + { concurrency: "unbounded" }, + ) + + yield* withGlobalConfigDir( + global, + withInstanceDir( + directory, + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(config.instruction_origins).toEqual([ + { spec: "global.md", source: global, scope: "global" }, + { spec: "project.md", source: path.join(directory, "opencode.json"), scope: "local" }, + { spec: "local.md", source: path.join(local, "opencode.json"), scope: "local" }, + ]) + }), + ), + ) + }), +) + it.effect("deduplicates duplicate plugins from global and local configs", () => withConfigTree( { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 3855e9c3a7fb..2755905783d8 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -21,9 +21,13 @@ const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSys const configLayer = TestConfig.layer() -const instructionLayer = (global: Partial, flags: Partial = {}) => +const instructionLayer = ( + global: Partial, + flags: Partial = {}, + config = configLayer, +) => Instruction.layer.pipe( - Layer.provide(configLayer), + Layer.provide(config), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Global.layerWith(global)), @@ -31,9 +35,9 @@ const instructionLayer = (global: Partial, flags: Partial, flags?: Partial) => + (global: Partial, flags?: Partial, config = configLayer) => (self: Effect.Effect) => - self.pipe(Effect.provide(instructionLayer(global, flags))) + self.pipe(Effect.provide(instructionLayer(global, flags, config))) const write = (filepath: string, content: string) => Effect.gen(function* () { @@ -56,6 +60,23 @@ const withFiles = (files: Record, self: (dir: string) = }), ) +function withProcessEnv(key: string, value: string | undefined, effect: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const original = process.env[key] + if (value === undefined) delete process.env[key] + if (value !== undefined) process.env[key] = value + return original + }), + () => effect, + (original) => + Effect.sync(() => { + if (original === undefined) delete process.env[key] + if (original !== undefined) process.env[key] = original + }), + ) +} + const tmpWithFiles = (files: Record) => Effect.gen(function* () { const dir = yield* tmpdirScoped() @@ -237,6 +258,31 @@ describe("Instruction.system", () => { ) }), ) + + it.live("loads explicit config instructions when project config is disabled", () => + Effect.gen(function* () { + const globalTmp = yield* tmpdirScoped() + const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Explicit Instructions" }) + const source = path.join(projectTmp, "custom-opencode.json") + + const config = TestConfig.layer({ + get: () => + Effect.succeed({ + instructions: ["AGENTS.md"], + instruction_origins: [{ spec: "AGENTS.md", source, scope: "local" }], + }), + }) + + yield* withProcessEnv( + "OPENCODE_DISABLE_PROJECT_CONFIG", + "true", + Effect.gen(function* () { + const rules = yield* (yield* Instruction.Service).system() + expect(rules).toEqual([`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Explicit Instructions`]) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }, {}, config)), + ) + }), + ) }) describe("Instruction.systemPaths global config", () => {