config.test.ts 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigModel } from "@opencode-ai/core/config/model"
  8. import { Config as ConfigSchema } from "@opencode-ai/schema/config"
  9. import { ConfigProvider } from "@opencode-ai/core/config/provider"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
  12. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  13. import { Credential } from "@opencode-ai/core/credential"
  14. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  15. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  16. import { FSUtil } from "@opencode-ai/util/fs-util"
  17. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  18. import { EventV2 } from "@opencode-ai/core/event"
  19. import { Global } from "@opencode-ai/util/global"
  20. import { Location } from "@opencode-ai/core/location"
  21. import { Project } from "@opencode-ai/core/project"
  22. import { ProviderV2 } from "@opencode-ai/core/provider"
  23. import { AbsolutePath } from "@opencode-ai/core/schema"
  24. import { WellKnown } from "@opencode-ai/core/wellknown"
  25. import { Integration } from "@opencode-ai/schema/integration"
  26. import { location } from "../fixture/location"
  27. import { tmpdir } from "../fixture/tmpdir"
  28. import { testEffect } from "../lib/effect"
  29. const it = testEffect(Layer.empty)
  30. const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
  31. const emptyCredentialNode = makeGlobalNode({
  32. service: Credential.Service,
  33. layer: Layer.succeed(
  34. Credential.Service,
  35. Credential.Service.of({
  36. all: () => Effect.succeed([]),
  37. list: () => Effect.succeed([]),
  38. get: () => Effect.succeed(undefined),
  39. create: () => Effect.die("unused Credential.create"),
  40. update: () => Effect.die("unused Credential.update"),
  41. remove: () => Effect.die("unused Credential.remove"),
  42. }),
  43. ),
  44. deps: [],
  45. })
  46. const emptyWellknownNode = makeGlobalNode({
  47. service: WellKnown.Service,
  48. layer: Layer.succeed(
  49. WellKnown.Service,
  50. WellKnown.Service.of({
  51. entries: () => Effect.succeed([]),
  52. snapshot: () => [],
  53. refresh: () => Effect.succeed(false),
  54. add: () => Effect.die("unused Wellknown.add"),
  55. remove: () => Effect.die("unused Wellknown.remove"),
  56. resolve: () => Effect.die("unused Wellknown.resolve"),
  57. }),
  58. ),
  59. deps: [],
  60. })
  61. function testLayer(
  62. directory: string,
  63. globalDirectory = path.join(directory, "global"),
  64. projectDirectory = directory,
  65. vcs?: Project.Vcs,
  66. watcher?: Layer.Layer<Watcher.Service>,
  67. credentialNode = emptyCredentialNode,
  68. wellknownNode = emptyWellknownNode,
  69. options?: Config.Options,
  70. ) {
  71. const locationLayer = Layer.succeed(
  72. Location.Service,
  73. Location.Service.of(
  74. location(
  75. { directory: AbsolutePath.make(directory) },
  76. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  77. ),
  78. ),
  79. )
  80. return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
  81. [Config.node, Config.configured(options)],
  82. [Location.node, locationLayer],
  83. [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
  84. [Credential.node, credentialNode],
  85. [WellKnown.node, wellknownNode],
  86. ...(watcher ? ([[Watcher.node, watcher]] as const) : []),
  87. ])
  88. }
  89. const provider = {
  90. package: "native",
  91. settings: {},
  92. headers: {},
  93. body: {},
  94. models: {},
  95. }
  96. describe("Config", () => {
  97. it.live("loads explicit file and content overrides in priority order", () =>
  98. Effect.acquireRelease(
  99. Effect.promise(() => tmpdir()),
  100. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  101. ).pipe(
  102. Effect.flatMap((tmp) => {
  103. const global = path.join(tmp.path, "global")
  104. const project = path.join(tmp.path, "project")
  105. const explicit = path.join(tmp.path, "custom.json")
  106. return Effect.promise(async () => {
  107. await fs.mkdir(global, { recursive: true })
  108. await fs.mkdir(project, { recursive: true })
  109. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  110. await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
  111. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  112. }).pipe(
  113. Effect.andThen(
  114. Effect.gen(function* () {
  115. const config = yield* Config.Service
  116. const entries = yield* config.entries()
  117. expect(
  118. entries.flatMap((entry) =>
  119. entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
  120. ),
  121. ).toEqual(["global", "explicit", "project", "content"])
  122. expect(Config.latest(entries, "shell")).toBe("content")
  123. }).pipe(
  124. Effect.provide(
  125. testLayer(
  126. project,
  127. global,
  128. project,
  129. undefined,
  130. undefined,
  131. emptyCredentialNode,
  132. emptyWellknownNode,
  133. { file: explicit, content: JSON.stringify({ shell: "content" }) },
  134. ),
  135. ),
  136. ),
  137. ),
  138. )
  139. }),
  140. ),
  141. )
  142. it.live("skips project configuration when project discovery is disabled", () =>
  143. Effect.acquireRelease(
  144. Effect.promise(() => tmpdir()),
  145. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  146. ).pipe(
  147. Effect.flatMap((tmp) => {
  148. const global = path.join(tmp.path, "global")
  149. const project = path.join(tmp.path, "project")
  150. return Effect.promise(async () => {
  151. await fs.mkdir(global, { recursive: true })
  152. await fs.mkdir(project, { recursive: true })
  153. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  154. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  155. }).pipe(
  156. Effect.andThen(
  157. Effect.gen(function* () {
  158. const config = yield* Config.Service
  159. expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
  160. }).pipe(
  161. Effect.provide(
  162. testLayer(
  163. project,
  164. global,
  165. project,
  166. undefined,
  167. undefined,
  168. emptyCredentialNode,
  169. emptyWellknownNode,
  170. { project: false },
  171. ),
  172. ),
  173. ),
  174. ),
  175. )
  176. }),
  177. ),
  178. )
  179. it.live("reloads external config and publishes directory updates", () =>
  180. Effect.acquireRelease(
  181. Effect.promise(() => tmpdir()),
  182. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  183. ).pipe(
  184. Effect.flatMap((tmp) =>
  185. Effect.gen(function* () {
  186. const global = path.join(tmp.path, "global")
  187. const project = path.join(tmp.path, "project")
  188. const file = path.join(global, "opencode.json")
  189. yield* Effect.promise(async () => {
  190. await fs.mkdir(global, { recursive: true })
  191. await fs.mkdir(project, { recursive: true })
  192. await fs.writeFile(file, JSON.stringify({ shell: "first" }))
  193. })
  194. const updates = yield* PubSub.unbounded<Watcher.Update>()
  195. const watcher = Layer.succeed(
  196. Watcher.Service,
  197. Watcher.Service.of({
  198. subscribe: () => Stream.fromPubSub(updates),
  199. }),
  200. )
  201. return yield* Effect.gen(function* () {
  202. const config = yield* Config.Service
  203. const events = yield* EventV2.Service
  204. const changed = yield* events
  205. .subscribe(ConfigSchema.Event.Updated)
  206. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  207. yield* Effect.sleep("10 millis")
  208. yield* PubSub.publish(updates, {
  209. type: "update",
  210. path: path.join(global, "commands", "review.md"),
  211. } satisfies Watcher.Update)
  212. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
  213. yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
  214. expect(yield* Fiber.join(changed)).toHaveLength(1)
  215. expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
  216. }).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
  217. }),
  218. ),
  219. ),
  220. )
  221. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  222. Effect.sync(() => {
  223. const entries = [
  224. new Config.Document({
  225. type: "document",
  226. info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
  227. }),
  228. new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  229. new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
  230. new Config.Document({ type: "document", info: new Config.Info({}) }),
  231. new Config.Document({
  232. type: "document",
  233. info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
  234. }),
  235. ]
  236. expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
  237. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  238. }),
  239. )
  240. it.live("loads authenticated wellknown config at highest priority", () =>
  241. Effect.acquireUseRelease(
  242. Effect.promise(() => tmpdir()),
  243. (tmp) =>
  244. Effect.gen(function* () {
  245. const global = path.join(tmp.path, "global")
  246. const project = path.join(tmp.path, "project")
  247. yield* Effect.promise(async () => {
  248. await fs.mkdir(global, { recursive: true })
  249. await fs.mkdir(project, { recursive: true })
  250. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  251. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  252. })
  253. const integrationID = Integration.ID.make("https://example.com")
  254. let key = "secret"
  255. const credentialNode = makeGlobalNode({
  256. service: Credential.Service,
  257. layer: Layer.succeed(
  258. Credential.Service,
  259. Credential.Service.of({
  260. all: () => Effect.die("unused Credential.all"),
  261. list: () =>
  262. Effect.succeed([
  263. new Credential.Info({
  264. id: Credential.ID.create(),
  265. integrationID,
  266. label: "default",
  267. value: Credential.Key.make({ type: "key", key }),
  268. }),
  269. ]),
  270. get: () => Effect.die("unused Credential.get"),
  271. create: () => Effect.die("unused Credential.create"),
  272. update: () => Effect.die("unused Credential.update"),
  273. remove: () => Effect.die("unused Credential.remove"),
  274. }),
  275. ),
  276. deps: [],
  277. })
  278. const entry: WellKnown.Entry = {
  279. origin: "https://example.com",
  280. integrationID,
  281. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  282. }
  283. const wellknownNode = makeGlobalNode({
  284. service: WellKnown.Service,
  285. layer: Layer.succeed(
  286. WellKnown.Service,
  287. WellKnown.Service.of({
  288. entries: () => Effect.succeed([entry]),
  289. snapshot: () => [entry],
  290. refresh: () => Effect.succeed(false),
  291. add: () => Effect.die("unused Wellknown.add"),
  292. remove: () => Effect.die("unused Wellknown.remove"),
  293. resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
  294. }),
  295. ),
  296. deps: [],
  297. })
  298. return yield* Effect.gen(function* () {
  299. const config = yield* Config.Service
  300. const events = yield* EventV2.Service
  301. expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
  302. const updated = yield* events
  303. .subscribe(ConfigSchema.Event.Updated)
  304. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  305. yield* Effect.yieldNow
  306. key = "next"
  307. yield* events.publish(Integration.Event.ConnectionUpdated, { integrationID })
  308. expect(yield* Fiber.join(updated)).toHaveLength(1)
  309. expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
  310. }).pipe(
  311. Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
  312. )
  313. }),
  314. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  315. ),
  316. )
  317. it.effect("detects v1 configuration from any v1-only top-level key", () =>
  318. Effect.sync(() => {
  319. expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
  320. expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
  321. expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
  322. expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
  323. expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
  324. }),
  325. )
  326. it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
  327. Effect.sync(() => {
  328. // V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
  329. expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
  330. expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
  331. // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated.
  332. expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
  333. expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
  334. expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
  335. }),
  336. )
  337. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  338. Effect.sync(() => {
  339. FastCheck.assert(
  340. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  341. const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
  342. Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
  343. Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
  344. ),
  345. )
  346. Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
  347. }),
  348. { numRuns: 100 },
  349. )
  350. }),
  351. )
  352. it.effect("migrates the v1 experimental subagent depth", () =>
  353. Effect.sync(() => {
  354. expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
  355. }),
  356. )
  357. it.effect("migrates v1 provider lists to policies", () =>
  358. Effect.sync(() => {
  359. expect(
  360. ConfigMigrateV1.migrate({
  361. enabled_providers: ["anthropic", "openai"],
  362. disabled_providers: ["openai"],
  363. }).experimental?.policies,
  364. ).toEqual([
  365. { action: "provider.use", resource: "*", effect: "deny" },
  366. { action: "provider.use", resource: "anthropic", effect: "allow" },
  367. { action: "provider.use", resource: "openai", effect: "allow" },
  368. { action: "provider.use", resource: "openai", effect: "deny" },
  369. ])
  370. expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
  371. { action: "provider.use", resource: "*", effect: "deny" },
  372. ])
  373. }),
  374. )
  375. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  376. Effect.sync(() => {
  377. const migrated = ConfigMigrateV1.migrate({
  378. provider: {
  379. bedrock: {
  380. npm: "@ai-sdk/amazon-bedrock",
  381. options: {
  382. headers: { "x-test": "1" },
  383. body: { trace: true },
  384. region: "us-east-1",
  385. profile: "dev",
  386. },
  387. },
  388. },
  389. })
  390. expect(migrated.providers?.bedrock).toMatchObject({
  391. package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"),
  392. settings: { region: "us-east-1", profile: "dev" },
  393. headers: { "x-test": "1" },
  394. body: { trace: true },
  395. })
  396. }),
  397. )
  398. it.effect("migrates v1 command configuration", () =>
  399. Effect.sync(() => {
  400. expect(
  401. ConfigMigrateV1.migrate({
  402. command: {
  403. review: {
  404. template: "Review changes",
  405. description: "Review code",
  406. agent: "reviewer",
  407. model: "anthropic/claude",
  408. variant: "high",
  409. subtask: true,
  410. },
  411. },
  412. }).commands,
  413. ).toEqual({
  414. review: {
  415. template: "Review changes",
  416. description: "Review code",
  417. agent: "reviewer",
  418. model: { providerID: "anthropic", model: "claude", variant: "high" },
  419. subtask: true,
  420. },
  421. })
  422. }),
  423. )
  424. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  425. Effect.sync(() => {
  426. expect(
  427. ConfigMigrateV1.migrate({
  428. permission: {
  429. task: "ask",
  430. bash: { "git status": "allow", "*": "deny" },
  431. write: "deny",
  432. read: "allow",
  433. },
  434. }).permissions,
  435. ).toEqual([
  436. { action: "subagent", resource: "*", effect: "ask" },
  437. { action: "shell", resource: "git status", effect: "allow" },
  438. { action: "shell", resource: "*", effect: "deny" },
  439. { action: "edit", resource: "*", effect: "deny" },
  440. { action: "read", resource: "*", effect: "allow" },
  441. ])
  442. }),
  443. )
  444. it.live("returns an empty configuration when directory files do not exist", () =>
  445. Effect.acquireRelease(
  446. Effect.promise(() => tmpdir()),
  447. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  448. ).pipe(
  449. Effect.flatMap((tmp) =>
  450. Effect.gen(function* () {
  451. const config = yield* Config.Service
  452. const entries = yield* config.entries()
  453. expect(entries).toEqual([
  454. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  455. ])
  456. }).pipe(Effect.provide(testLayer(tmp.path))),
  457. ),
  458. ),
  459. )
  460. it.live("does not watch ecosystem config roots", () =>
  461. Effect.acquireRelease(
  462. Effect.promise(() => tmpdir()),
  463. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  464. ).pipe(
  465. Effect.flatMap((tmp) =>
  466. Effect.gen(function* () {
  467. yield* Effect.promise(() =>
  468. Promise.all([
  469. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  470. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  471. ]),
  472. )
  473. const targets: Watcher.WatchInput[] = []
  474. const watcher = Layer.succeed(
  475. Watcher.Service,
  476. Watcher.Service.of({
  477. subscribe: (input) => {
  478. targets.push(input)
  479. return Stream.never
  480. },
  481. }),
  482. )
  483. return yield* Effect.gen(function* () {
  484. const config = yield* Config.Service
  485. yield* config.entries()
  486. expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }])
  487. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
  488. }),
  489. ),
  490. ),
  491. )
  492. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  493. Effect.acquireRelease(
  494. Effect.promise(() => tmpdir()),
  495. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  496. ).pipe(
  497. Effect.flatMap((tmp) =>
  498. Effect.gen(function* () {
  499. yield* Effect.promise(() =>
  500. Promise.all([
  501. fs.writeFile(
  502. path.join(tmp.path, "opencode.json"),
  503. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  504. ),
  505. fs.writeFile(
  506. path.join(tmp.path, "opencode.jsonc"),
  507. `{
  508. // Later global files override scalar fields while retaining providers.
  509. "$schema": "last",
  510. "providers": { "last": ${JSON.stringify(provider)} },
  511. }`,
  512. ),
  513. ]),
  514. )
  515. return yield* Effect.gen(function* () {
  516. const config = yield* Config.Service
  517. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  518. expect(documents).toHaveLength(2)
  519. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  520. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  521. expect(documents[0]).toBeInstanceOf(Config.Document)
  522. expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
  523. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  524. yield* Effect.promise(() =>
  525. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  526. )
  527. expect(
  528. (yield* config.entries())
  529. .filter((entry) => entry.type === "document")
  530. .map((document) => document.info.$schema),
  531. ).toEqual(["base", "last"])
  532. }).pipe(Effect.provide(testLayer(tmp.path)))
  533. }),
  534. ),
  535. ),
  536. )
  537. it.live("substitutes environment variables and relative file contents", () =>
  538. Effect.acquireUseRelease(
  539. Effect.sync(() => {
  540. const previous = {
  541. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  542. missing: process.env.OPENCODE_TEST_MISSING,
  543. }
  544. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  545. delete process.env.OPENCODE_TEST_MISSING
  546. return previous
  547. }),
  548. () =>
  549. Effect.acquireUseRelease(
  550. Effect.promise(() => tmpdir()),
  551. (tmp) =>
  552. Effect.gen(function* () {
  553. yield* Effect.promise(() =>
  554. Promise.all([
  555. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  556. fs.writeFile(
  557. path.join(tmp.path, "opencode.jsonc"),
  558. `{
  559. // Ignored reference: {file:missing.txt}
  560. "username": "user-{env:OPENCODE_TEST_MISSING}",
  561. "mcp": {
  562. "servers": {
  563. "remote": {
  564. "type": "remote",
  565. "url": "https://example.com/mcp",
  566. "headers": {
  567. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  568. "X-Token": "{file:token.txt}"
  569. }
  570. }
  571. }
  572. }
  573. }`,
  574. ),
  575. ]),
  576. )
  577. return yield* Effect.gen(function* () {
  578. const config = yield* Config.Service
  579. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  580. expect(document?.info.username).toBe("user-")
  581. const remote = document?.info.mcp?.servers?.remote
  582. expect(remote?.type).toBe("remote")
  583. if (remote?.type !== "remote") return
  584. expect(remote.headers).toEqual({
  585. Authorization: "Bearer secret",
  586. "X-Token": 'file\n"token"',
  587. })
  588. }).pipe(Effect.provide(testLayer(tmp.path)))
  589. }),
  590. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  591. ),
  592. (previous) =>
  593. Effect.sync(() => {
  594. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  595. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  596. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  597. else process.env.OPENCODE_TEST_MISSING = previous.missing
  598. }),
  599. ),
  600. )
  601. it.live("does not load legacy config.json files", () =>
  602. Effect.acquireRelease(
  603. Effect.promise(() => tmpdir()),
  604. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  605. ).pipe(
  606. Effect.flatMap((tmp) =>
  607. Effect.gen(function* () {
  608. yield* Effect.promise(() =>
  609. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  610. )
  611. return yield* Effect.gen(function* () {
  612. const config = yield* Config.Service
  613. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  614. expect(documents).toHaveLength(0)
  615. }).pipe(Effect.provide(testLayer(tmp.path)))
  616. }),
  617. ),
  618. ),
  619. )
  620. it.live("accepts $schema metadata without writing it into config files", () =>
  621. Effect.acquireRelease(
  622. Effect.promise(() => tmpdir()),
  623. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  624. ).pipe(
  625. Effect.flatMap((tmp) =>
  626. Effect.gen(function* () {
  627. const file = path.join(tmp.path, "opencode.json")
  628. const contents = JSON.stringify({
  629. shell: "/bin/zsh",
  630. providers: { local: provider },
  631. })
  632. yield* Effect.promise(() => fs.writeFile(file, contents))
  633. return yield* Effect.gen(function* () {
  634. const config = yield* Config.Service
  635. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  636. expect(documents[0]?.info.$schema).toBeUndefined()
  637. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  638. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  639. }).pipe(Effect.provide(testLayer(tmp.path)))
  640. }),
  641. ),
  642. ),
  643. )
  644. it.live("loads supported scalar and resource configuration", () =>
  645. Effect.acquireRelease(
  646. Effect.promise(() => tmpdir()),
  647. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  648. ).pipe(
  649. Effect.flatMap((tmp) =>
  650. Effect.gen(function* () {
  651. yield* Effect.promise(() =>
  652. fs.writeFile(
  653. path.join(tmp.path, "opencode.json"),
  654. JSON.stringify({
  655. shell: "/bin/bash",
  656. model: "anthropic/claude",
  657. default_agent: "reviewer",
  658. autoupdate: "notify",
  659. share: "disabled",
  660. enterprise: { url: "https://share.example.com" },
  661. username: "test-user",
  662. permissions: [
  663. { action: "bash", resource: "*", effect: "ask" },
  664. { action: "bash", resource: "git status", effect: "allow" },
  665. ],
  666. agents: {
  667. reviewer: {
  668. model: "openrouter/openai/gpt-5#high",
  669. request: {
  670. headers: { "x-agent": "reviewer" },
  671. body: { reasoningEffort: "high" },
  672. },
  673. description: "Review changes for correctness",
  674. system: "Find regressions.",
  675. mode: "subagent",
  676. hidden: false,
  677. color: "warning",
  678. steps: 12,
  679. disabled: false,
  680. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  681. },
  682. },
  683. snapshots: false,
  684. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  685. formatter: {
  686. prettier: { disabled: true },
  687. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  688. },
  689. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  690. attachments: {
  691. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  692. },
  693. tool_output: { max_lines: 1000, max_bytes: 32768 },
  694. mcp: {
  695. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  696. servers: {
  697. local: {
  698. type: "local",
  699. command: ["node", "./mcp/server.js"],
  700. environment: { API_KEY: "secret" },
  701. disabled: false,
  702. codemode: false,
  703. timeout: { catalog: 10000 },
  704. },
  705. remote: {
  706. type: "remote",
  707. url: "https://mcp.example.com/mcp",
  708. headers: { Authorization: "Bearer token" },
  709. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  710. disabled: true,
  711. codemode: false,
  712. timeout: { startup: 15000 },
  713. },
  714. },
  715. },
  716. compaction: {
  717. auto: true,
  718. prune: false,
  719. keep: { tokens: 2000 },
  720. buffer: 10000,
  721. },
  722. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  723. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  724. references: {
  725. local: { path: "../library" },
  726. sdk: { repository: "github.com/example/sdk", branch: "main" },
  727. shorthand: "github.com/example/docs",
  728. },
  729. plugins: [
  730. "opencode-helicone-session",
  731. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  732. ],
  733. }),
  734. ),
  735. )
  736. return yield* Effect.gen(function* () {
  737. const config = yield* Config.Service
  738. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  739. expect(documents).toHaveLength(1)
  740. expect(documents[0]?.info.shell).toBe("/bin/bash")
  741. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  742. expect(documents[0]?.info.default_agent).toBe("reviewer")
  743. expect(documents[0]?.info.autoupdate).toBe("notify")
  744. expect(documents[0]?.info.share).toBe("disabled")
  745. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  746. expect(documents[0]?.info.username).toBe("test-user")
  747. expect(documents[0]?.info.permissions).toEqual([
  748. { action: "bash", resource: "*", effect: "ask" },
  749. { action: "bash", resource: "git status", effect: "allow" },
  750. ])
  751. const reviewer = documents[0]?.info.agents?.reviewer
  752. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  753. expect(reviewer?.request).toEqual({
  754. headers: { "x-agent": "reviewer" },
  755. body: { reasoningEffort: "high" },
  756. })
  757. expect(reviewer?.description).toBe("Review changes for correctness")
  758. expect(reviewer?.system).toBe("Find regressions.")
  759. expect(reviewer?.mode).toBe("subagent")
  760. expect(reviewer?.hidden).toBe(false)
  761. expect(reviewer?.color).toBe("warning")
  762. expect(reviewer?.steps).toBe(12)
  763. expect(reviewer?.disabled).toBe(false)
  764. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  765. expect(documents[0]?.info.snapshots).toBe(false)
  766. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  767. expect(documents[0]?.info.formatter).toEqual({
  768. prettier: { disabled: true },
  769. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  770. })
  771. expect(documents[0]?.info.lsp).toEqual({
  772. typescript: { disabled: true },
  773. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  774. })
  775. expect(documents[0]?.info.attachments).toEqual({
  776. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  777. })
  778. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  779. expect(documents[0]?.info.mcp).toEqual({
  780. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  781. servers: {
  782. local: {
  783. type: "local",
  784. command: ["node", "./mcp/server.js"],
  785. environment: { API_KEY: "secret" },
  786. disabled: false,
  787. codemode: false,
  788. timeout: { catalog: 10000 },
  789. },
  790. remote: {
  791. type: "remote",
  792. url: "https://mcp.example.com/mcp",
  793. headers: { Authorization: "Bearer token" },
  794. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  795. disabled: true,
  796. codemode: false,
  797. timeout: { startup: 15000 },
  798. },
  799. },
  800. })
  801. expect(documents[0]?.info.compaction).toEqual({
  802. auto: true,
  803. keep: { tokens: 2000 },
  804. buffer: 10000,
  805. })
  806. expect(documents[0]?.info.skills).toEqual([
  807. "./skills",
  808. "~/shared-skills",
  809. "https://example.com/.well-known/skills/",
  810. ])
  811. expect(documents[0]?.info.instructions).toEqual([
  812. "CONTRIBUTING.md",
  813. ".cursor/rules/*.md",
  814. "https://example.com/shared-rules.md",
  815. ])
  816. expect(documents[0]?.info.references).toEqual({
  817. local: { path: "../library" },
  818. sdk: { repository: "github.com/example/sdk", branch: "main" },
  819. shorthand: "github.com/example/docs",
  820. })
  821. expect(documents[0]?.info.plugins).toEqual([
  822. "opencode-helicone-session",
  823. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  824. ])
  825. }).pipe(Effect.provide(testLayer(tmp.path)))
  826. }),
  827. ),
  828. ),
  829. )
  830. it.live("migrates the deprecated reference key into references", () =>
  831. Effect.acquireRelease(
  832. Effect.promise(() => tmpdir()),
  833. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  834. ).pipe(
  835. Effect.flatMap((tmp) =>
  836. Effect.gen(function* () {
  837. yield* Effect.promise(() =>
  838. fs.writeFile(
  839. path.join(tmp.path, "opencode.json"),
  840. JSON.stringify({
  841. reference: {
  842. local: { path: "../library" },
  843. sdk: { repository: "github.com/example/sdk", branch: "main" },
  844. shorthand: "github.com/example/docs",
  845. },
  846. }),
  847. ),
  848. )
  849. return yield* Effect.gen(function* () {
  850. const config = yield* Config.Service
  851. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  852. expect(documents).toHaveLength(1)
  853. expect(documents[0]?.info.references).toEqual({
  854. local: { path: "../library" },
  855. sdk: { repository: "github.com/example/sdk", branch: "main" },
  856. shorthand: "github.com/example/docs",
  857. })
  858. }).pipe(Effect.provide(testLayer(tmp.path)))
  859. }),
  860. ),
  861. ),
  862. )
  863. it.live("migrates v1 configuration when a v1-only key is present", () =>
  864. Effect.acquireRelease(
  865. Effect.promise(() => tmpdir()),
  866. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  867. ).pipe(
  868. Effect.flatMap((tmp) =>
  869. Effect.gen(function* () {
  870. yield* Effect.promise(() =>
  871. fs.writeFile(
  872. path.join(tmp.path, "opencode.json"),
  873. JSON.stringify({
  874. shell: "/bin/zsh",
  875. default_agent: "reviewer",
  876. snapshot: false,
  877. autoshare: true,
  878. permission: {
  879. bash: "ask",
  880. edit: { "*.md": "allow", "*": "deny" },
  881. question: "deny",
  882. },
  883. agent: {
  884. reviewer: {
  885. prompt: "Review changes.",
  886. disable: true,
  887. temperature: 0.2,
  888. permission: { read: "allow" },
  889. },
  890. },
  891. plugin: [
  892. "opencode-helicone-session",
  893. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  894. ],
  895. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  896. references: {
  897. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  898. },
  899. attachment: { image: { auto_resize: false, max_width: 1200 } },
  900. provider: {
  901. custom: {
  902. options: { apiKey: "secret" },
  903. models: {
  904. model: {
  905. options: { reasoningEffort: "high" },
  906. variants: { fast: { temperature: 0.2 } },
  907. },
  908. },
  909. },
  910. openai: {
  911. npm: "@ai-sdk/openai",
  912. options: { apiKey: "secret", organization: "org" },
  913. models: {
  914. model: {
  915. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  916. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  917. },
  918. },
  919. },
  920. anthropic: {
  921. npm: "@ai-sdk/anthropic",
  922. models: {
  923. model: {
  924. options: {
  925. effort: "high",
  926. taskBudget: 4096,
  927. metadata: { userId: "user-1" },
  928. },
  929. },
  930. },
  931. },
  932. },
  933. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  934. experimental: { mcp_timeout: 5000 },
  935. mcp: {
  936. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  937. remote: {
  938. type: "remote",
  939. url: "https://mcp.example.com",
  940. oauth: { clientId: "client", callbackPort: 19876 },
  941. timeout: 20000,
  942. },
  943. },
  944. }),
  945. ),
  946. )
  947. return yield* Effect.gen(function* () {
  948. const config = yield* Config.Service
  949. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  950. expect(documents).toHaveLength(1)
  951. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  952. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  953. expect(documents[0]?.info.default_agent).toBe("reviewer")
  954. expect(documents[0]?.info.snapshots).toBe(false)
  955. expect(documents[0]?.info.share).toBe("auto")
  956. expect(documents[0]?.info.permissions).toEqual([
  957. { action: "shell", resource: "*", effect: "ask" },
  958. { action: "edit", resource: "*.md", effect: "allow" },
  959. { action: "edit", resource: "*", effect: "deny" },
  960. { action: "question", resource: "*", effect: "deny" },
  961. ])
  962. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  963. system: "Review changes.",
  964. disabled: true,
  965. request: { body: { temperature: 0.2 } },
  966. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  967. })
  968. expect(documents[0]?.info.plugins).toEqual([
  969. "opencode-helicone-session",
  970. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  971. ])
  972. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  973. expect(documents[0]?.info.references).toEqual({
  974. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  975. })
  976. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  977. expect(documents[0]?.info.providers?.custom).toMatchObject({
  978. settings: { apiKey: "secret" },
  979. models: {
  980. model: {
  981. settings: { reasoningEffort: "high" },
  982. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  983. },
  984. },
  985. })
  986. expect(documents[0]?.info.providers?.openai).toMatchObject({
  987. package: ProviderV2.aisdk("@ai-sdk/openai"),
  988. settings: { apiKey: "secret", organization: "org" },
  989. models: {
  990. model: {
  991. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  992. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  993. },
  994. },
  995. })
  996. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  997. package: ProviderV2.aisdk("@ai-sdk/anthropic"),
  998. models: {
  999. model: {
  1000. settings: {
  1001. effort: "high",
  1002. taskBudget: 4096,
  1003. metadata: { userId: "user-1" },
  1004. },
  1005. },
  1006. },
  1007. })
  1008. expect(documents[0]?.info.compaction).toEqual({
  1009. auto: true,
  1010. keep: { tokens: 2000 },
  1011. buffer: 10000,
  1012. })
  1013. expect(documents[0]?.info.mcp).toMatchObject({
  1014. timeout: { catalog: 5000, execution: 5000 },
  1015. servers: {
  1016. local: {
  1017. type: "local",
  1018. command: ["node", "server.js"],
  1019. disabled: true,
  1020. timeout: { catalog: 10000, execution: 10000 },
  1021. },
  1022. remote: {
  1023. type: "remote",
  1024. url: "https://mcp.example.com",
  1025. oauth: { client_id: "client", callback_port: 19876 },
  1026. timeout: { catalog: 20000, execution: 20000 },
  1027. },
  1028. },
  1029. })
  1030. }).pipe(Effect.provide(testLayer(tmp.path)))
  1031. }),
  1032. ),
  1033. ),
  1034. )
  1035. it.live("ignores an invalid file while loading valid config values", () =>
  1036. Effect.acquireRelease(
  1037. Effect.promise(() => tmpdir()),
  1038. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1039. ).pipe(
  1040. Effect.flatMap((tmp) =>
  1041. Effect.gen(function* () {
  1042. yield* Effect.promise(() =>
  1043. Promise.all([
  1044. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1045. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1046. ]),
  1047. )
  1048. return yield* Effect.gen(function* () {
  1049. const config = yield* Config.Service
  1050. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1051. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1052. }).pipe(Effect.provide(testLayer(tmp.path)))
  1053. }),
  1054. ),
  1055. ),
  1056. )
  1057. it.live("loads global and ancestor configuration across the project boundary", () =>
  1058. Effect.acquireRelease(
  1059. Effect.promise(() => tmpdir()),
  1060. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1061. ).pipe(
  1062. Effect.flatMap((tmp) => {
  1063. const global = path.join(tmp.path, "global")
  1064. const root = path.join(tmp.path, "repo")
  1065. const parent = path.join(root, "packages")
  1066. const directory = path.join(parent, "app")
  1067. const globalAgents = path.join(global, "home", ".agents")
  1068. const globalClaude = path.join(global, "home", ".claude")
  1069. return Effect.gen(function* () {
  1070. yield* Effect.promise(async () => {
  1071. await fs.mkdir(global, { recursive: true })
  1072. await fs.mkdir(globalAgents, { recursive: true })
  1073. await fs.mkdir(globalClaude, { recursive: true })
  1074. await fs.mkdir(directory, { recursive: true })
  1075. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1076. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1077. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1078. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1079. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1080. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1081. await Promise.all([
  1082. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1083. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1084. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1085. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1086. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1087. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1088. fs.writeFile(
  1089. path.join(directory, ".opencode", "opencode.jsonc"),
  1090. JSON.stringify({ $schema: "directory-dot" }),
  1091. ),
  1092. ])
  1093. })
  1094. return yield* Effect.gen(function* () {
  1095. const config = yield* Config.Service
  1096. const entries = yield* config.entries()
  1097. const documents = entries.filter((entry) => entry.type === "document")
  1098. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1099. AbsolutePath.make(global),
  1100. AbsolutePath.make(path.join(root, ".opencode")),
  1101. AbsolutePath.make(path.join(directory, ".opencode")),
  1102. ])
  1103. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1104. AbsolutePath.make(globalAgents),
  1105. AbsolutePath.make(path.join(directory, ".agents")),
  1106. AbsolutePath.make(path.join(root, ".agents")),
  1107. ])
  1108. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1109. AbsolutePath.make(globalClaude),
  1110. AbsolutePath.make(path.join(directory, ".claude")),
  1111. AbsolutePath.make(path.join(root, ".claude")),
  1112. ])
  1113. expect(documents.map((document) => document.info.$schema)).toEqual([
  1114. "global",
  1115. "outside",
  1116. "root",
  1117. "parent",
  1118. "directory",
  1119. "root-dot",
  1120. "directory-dot",
  1121. ])
  1122. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1123. AbsolutePath.make(globalClaude),
  1124. AbsolutePath.make(path.join(directory, ".claude")),
  1125. AbsolutePath.make(path.join(root, ".claude")),
  1126. AbsolutePath.make(globalAgents),
  1127. AbsolutePath.make(path.join(directory, ".agents")),
  1128. AbsolutePath.make(path.join(root, ".agents")),
  1129. "global",
  1130. AbsolutePath.make(global),
  1131. "outside",
  1132. AbsolutePath.make(path.join(tmp.path, "opencode.json")),
  1133. "root",
  1134. AbsolutePath.make(path.join(root, "opencode.json")),
  1135. "parent",
  1136. AbsolutePath.make(path.join(parent, "opencode.jsonc")),
  1137. "directory",
  1138. AbsolutePath.make(path.join(directory, "opencode.json")),
  1139. "root-dot",
  1140. AbsolutePath.make(path.join(root, ".opencode")),
  1141. "directory-dot",
  1142. AbsolutePath.make(path.join(directory, ".opencode")),
  1143. ])
  1144. }).pipe(
  1145. Effect.provide(
  1146. testLayer(directory, global, root, {
  1147. type: "git",
  1148. store: AbsolutePath.make(path.join(root, ".git")),
  1149. }),
  1150. ),
  1151. )
  1152. })
  1153. }),
  1154. ),
  1155. )
  1156. })