config.test.ts 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233
  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 interleaved fields to compatibility", () =>
  399. Effect.sync(() => {
  400. const migrated = ConfigMigrateV1.migrate({
  401. provider: {
  402. custom: {
  403. models: {
  404. object: { interleaved: { field: "vendor_reasoning" } },
  405. string: { interleaved: "reasoning_text" },
  406. boolean: { interleaved: true },
  407. },
  408. },
  409. },
  410. })
  411. expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
  412. reasoningField: "vendor_reasoning",
  413. })
  414. expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
  415. expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
  416. }),
  417. )
  418. it.effect("migrates v1 command configuration", () =>
  419. Effect.sync(() => {
  420. expect(
  421. ConfigMigrateV1.migrate({
  422. command: {
  423. review: {
  424. template: "Review changes",
  425. description: "Review code",
  426. agent: "reviewer",
  427. model: "anthropic/claude",
  428. variant: "high",
  429. subtask: true,
  430. },
  431. },
  432. }).commands,
  433. ).toEqual({
  434. review: {
  435. template: "Review changes",
  436. description: "Review code",
  437. agent: "reviewer",
  438. model: { providerID: "anthropic", model: "claude", variant: "high" },
  439. subtask: true,
  440. },
  441. })
  442. }),
  443. )
  444. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  445. Effect.sync(() => {
  446. expect(
  447. ConfigMigrateV1.migrate({
  448. permission: {
  449. task: "ask",
  450. bash: { "git status": "allow", "*": "deny" },
  451. write: "deny",
  452. read: "allow",
  453. },
  454. }).permissions,
  455. ).toEqual([
  456. { action: "subagent", resource: "*", effect: "ask" },
  457. { action: "shell", resource: "git status", effect: "allow" },
  458. { action: "shell", resource: "*", effect: "deny" },
  459. { action: "edit", resource: "*", effect: "deny" },
  460. { action: "read", resource: "*", effect: "allow" },
  461. ])
  462. }),
  463. )
  464. it.live("returns an empty configuration when directory files do not exist", () =>
  465. Effect.acquireRelease(
  466. Effect.promise(() => tmpdir()),
  467. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  468. ).pipe(
  469. Effect.flatMap((tmp) =>
  470. Effect.gen(function* () {
  471. const config = yield* Config.Service
  472. const entries = yield* config.entries()
  473. expect(entries).toEqual([
  474. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  475. ])
  476. }).pipe(Effect.provide(testLayer(tmp.path))),
  477. ),
  478. ),
  479. )
  480. it.live("does not watch ecosystem config roots", () =>
  481. Effect.acquireRelease(
  482. Effect.promise(() => tmpdir()),
  483. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  484. ).pipe(
  485. Effect.flatMap((tmp) =>
  486. Effect.gen(function* () {
  487. yield* Effect.promise(() =>
  488. Promise.all([
  489. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  490. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  491. ]),
  492. )
  493. const targets: Watcher.WatchInput[] = []
  494. const watcher = Layer.succeed(
  495. Watcher.Service,
  496. Watcher.Service.of({
  497. subscribe: (input) => {
  498. targets.push(input)
  499. return Stream.never
  500. },
  501. }),
  502. )
  503. return yield* Effect.gen(function* () {
  504. const config = yield* Config.Service
  505. yield* config.entries()
  506. expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }])
  507. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
  508. }),
  509. ),
  510. ),
  511. )
  512. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  513. Effect.acquireRelease(
  514. Effect.promise(() => tmpdir()),
  515. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  516. ).pipe(
  517. Effect.flatMap((tmp) =>
  518. Effect.gen(function* () {
  519. yield* Effect.promise(() =>
  520. Promise.all([
  521. fs.writeFile(
  522. path.join(tmp.path, "opencode.json"),
  523. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  524. ),
  525. fs.writeFile(
  526. path.join(tmp.path, "opencode.jsonc"),
  527. `{
  528. // Later global files override scalar fields while retaining providers.
  529. "$schema": "last",
  530. "providers": { "last": ${JSON.stringify(provider)} },
  531. }`,
  532. ),
  533. ]),
  534. )
  535. return yield* Effect.gen(function* () {
  536. const config = yield* Config.Service
  537. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  538. expect(documents).toHaveLength(2)
  539. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  540. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  541. expect(documents[0]).toBeInstanceOf(Config.Document)
  542. expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
  543. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  544. yield* Effect.promise(() =>
  545. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  546. )
  547. expect(
  548. (yield* config.entries())
  549. .filter((entry) => entry.type === "document")
  550. .map((document) => document.info.$schema),
  551. ).toEqual(["base", "last"])
  552. }).pipe(Effect.provide(testLayer(tmp.path)))
  553. }),
  554. ),
  555. ),
  556. )
  557. it.live("substitutes environment variables and relative file contents", () =>
  558. Effect.acquireUseRelease(
  559. Effect.sync(() => {
  560. const previous = {
  561. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  562. missing: process.env.OPENCODE_TEST_MISSING,
  563. }
  564. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  565. delete process.env.OPENCODE_TEST_MISSING
  566. return previous
  567. }),
  568. () =>
  569. Effect.acquireUseRelease(
  570. Effect.promise(() => tmpdir()),
  571. (tmp) =>
  572. Effect.gen(function* () {
  573. yield* Effect.promise(() =>
  574. Promise.all([
  575. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  576. fs.writeFile(
  577. path.join(tmp.path, "opencode.jsonc"),
  578. `{
  579. // Ignored reference: {file:missing.txt}
  580. "username": "user-{env:OPENCODE_TEST_MISSING}",
  581. "mcp": {
  582. "servers": {
  583. "remote": {
  584. "type": "remote",
  585. "url": "https://example.com/mcp",
  586. "headers": {
  587. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  588. "X-Token": "{file:token.txt}"
  589. }
  590. }
  591. }
  592. }
  593. }`,
  594. ),
  595. ]),
  596. )
  597. return yield* Effect.gen(function* () {
  598. const config = yield* Config.Service
  599. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  600. expect(document?.info.username).toBe("user-")
  601. const remote = document?.info.mcp?.servers?.remote
  602. expect(remote?.type).toBe("remote")
  603. if (remote?.type !== "remote") return
  604. expect(remote.headers).toEqual({
  605. Authorization: "Bearer secret",
  606. "X-Token": 'file\n"token"',
  607. })
  608. }).pipe(Effect.provide(testLayer(tmp.path)))
  609. }),
  610. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  611. ),
  612. (previous) =>
  613. Effect.sync(() => {
  614. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  615. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  616. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  617. else process.env.OPENCODE_TEST_MISSING = previous.missing
  618. }),
  619. ),
  620. )
  621. it.live("does not load legacy config.json files", () =>
  622. Effect.acquireRelease(
  623. Effect.promise(() => tmpdir()),
  624. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  625. ).pipe(
  626. Effect.flatMap((tmp) =>
  627. Effect.gen(function* () {
  628. yield* Effect.promise(() =>
  629. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  630. )
  631. return yield* Effect.gen(function* () {
  632. const config = yield* Config.Service
  633. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  634. expect(documents).toHaveLength(0)
  635. }).pipe(Effect.provide(testLayer(tmp.path)))
  636. }),
  637. ),
  638. ),
  639. )
  640. it.live("accepts $schema metadata without writing it into config files", () =>
  641. Effect.acquireRelease(
  642. Effect.promise(() => tmpdir()),
  643. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  644. ).pipe(
  645. Effect.flatMap((tmp) =>
  646. Effect.gen(function* () {
  647. const file = path.join(tmp.path, "opencode.json")
  648. const contents = JSON.stringify({
  649. shell: "/bin/zsh",
  650. providers: { local: provider },
  651. })
  652. yield* Effect.promise(() => fs.writeFile(file, contents))
  653. return yield* Effect.gen(function* () {
  654. const config = yield* Config.Service
  655. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  656. expect(documents[0]?.info.$schema).toBeUndefined()
  657. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  658. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  659. }).pipe(Effect.provide(testLayer(tmp.path)))
  660. }),
  661. ),
  662. ),
  663. )
  664. it.live("loads supported scalar and resource configuration", () =>
  665. Effect.acquireRelease(
  666. Effect.promise(() => tmpdir()),
  667. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  668. ).pipe(
  669. Effect.flatMap((tmp) =>
  670. Effect.gen(function* () {
  671. yield* Effect.promise(() =>
  672. fs.writeFile(
  673. path.join(tmp.path, "opencode.json"),
  674. JSON.stringify({
  675. shell: "/bin/bash",
  676. model: "anthropic/claude",
  677. default_agent: "reviewer",
  678. autoupdate: "notify",
  679. share: "disabled",
  680. enterprise: { url: "https://share.example.com" },
  681. username: "test-user",
  682. permissions: [
  683. { action: "bash", resource: "*", effect: "ask" },
  684. { action: "bash", resource: "git status", effect: "allow" },
  685. ],
  686. agents: {
  687. reviewer: {
  688. model: "openrouter/openai/gpt-5#high",
  689. request: {
  690. headers: { "x-agent": "reviewer" },
  691. body: { reasoningEffort: "high" },
  692. },
  693. description: "Review changes for correctness",
  694. system: "Find regressions.",
  695. mode: "subagent",
  696. hidden: false,
  697. color: "#ff6b6b",
  698. steps: 12,
  699. disabled: false,
  700. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  701. },
  702. },
  703. snapshots: false,
  704. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  705. formatter: {
  706. prettier: { disabled: true },
  707. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  708. },
  709. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  710. attachments: {
  711. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  712. },
  713. tool_output: { max_lines: 1000, max_bytes: 32768 },
  714. mcp: {
  715. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  716. servers: {
  717. local: {
  718. type: "local",
  719. command: ["node", "./mcp/server.js"],
  720. environment: { API_KEY: "secret" },
  721. disabled: false,
  722. codemode: false,
  723. timeout: { catalog: 10000 },
  724. },
  725. remote: {
  726. type: "remote",
  727. url: "https://mcp.example.com/mcp",
  728. headers: { Authorization: "Bearer token" },
  729. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  730. disabled: true,
  731. codemode: false,
  732. timeout: { startup: 15000 },
  733. },
  734. },
  735. },
  736. compaction: {
  737. auto: true,
  738. prune: false,
  739. keep: { tokens: 2000 },
  740. buffer: 10000,
  741. },
  742. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  743. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  744. references: {
  745. local: { path: "../library" },
  746. sdk: { repository: "github.com/example/sdk", branch: "main" },
  747. shorthand: "github.com/example/docs",
  748. },
  749. plugins: [
  750. "opencode-helicone-session",
  751. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  752. ],
  753. }),
  754. ),
  755. )
  756. return yield* Effect.gen(function* () {
  757. const config = yield* Config.Service
  758. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  759. expect(documents).toHaveLength(1)
  760. expect(documents[0]?.info.shell).toBe("/bin/bash")
  761. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  762. expect(documents[0]?.info.default_agent).toBe("reviewer")
  763. expect(documents[0]?.info.autoupdate).toBe("notify")
  764. expect(documents[0]?.info.share).toBe("disabled")
  765. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  766. expect(documents[0]?.info.username).toBe("test-user")
  767. expect(documents[0]?.info.permissions).toEqual([
  768. { action: "bash", resource: "*", effect: "ask" },
  769. { action: "bash", resource: "git status", effect: "allow" },
  770. ])
  771. const reviewer = documents[0]?.info.agents?.reviewer
  772. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  773. expect(reviewer?.request).toEqual({
  774. headers: { "x-agent": "reviewer" },
  775. body: { reasoningEffort: "high" },
  776. })
  777. expect(reviewer?.description).toBe("Review changes for correctness")
  778. expect(reviewer?.system).toBe("Find regressions.")
  779. expect(reviewer?.mode).toBe("subagent")
  780. expect(reviewer?.hidden).toBe(false)
  781. expect(reviewer?.color).toBe("#ff6b6b")
  782. expect(reviewer?.steps).toBe(12)
  783. expect(reviewer?.disabled).toBe(false)
  784. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  785. expect(documents[0]?.info.snapshots).toBe(false)
  786. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  787. expect(documents[0]?.info.formatter).toEqual({
  788. prettier: { disabled: true },
  789. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  790. })
  791. expect(documents[0]?.info.lsp).toEqual({
  792. typescript: { disabled: true },
  793. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  794. })
  795. expect(documents[0]?.info.attachments).toEqual({
  796. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  797. })
  798. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  799. expect(documents[0]?.info.mcp).toEqual({
  800. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  801. servers: {
  802. local: {
  803. type: "local",
  804. command: ["node", "./mcp/server.js"],
  805. environment: { API_KEY: "secret" },
  806. disabled: false,
  807. codemode: false,
  808. timeout: { catalog: 10000 },
  809. },
  810. remote: {
  811. type: "remote",
  812. url: "https://mcp.example.com/mcp",
  813. headers: { Authorization: "Bearer token" },
  814. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  815. disabled: true,
  816. codemode: false,
  817. timeout: { startup: 15000 },
  818. },
  819. },
  820. })
  821. expect(documents[0]?.info.compaction).toEqual({
  822. auto: true,
  823. keep: { tokens: 2000 },
  824. buffer: 10000,
  825. })
  826. expect(documents[0]?.info.skills).toEqual([
  827. "./skills",
  828. "~/shared-skills",
  829. "https://example.com/.well-known/skills/",
  830. ])
  831. expect(documents[0]?.info.instructions).toEqual([
  832. "CONTRIBUTING.md",
  833. ".cursor/rules/*.md",
  834. "https://example.com/shared-rules.md",
  835. ])
  836. expect(documents[0]?.info.references).toEqual({
  837. local: { path: "../library" },
  838. sdk: { repository: "github.com/example/sdk", branch: "main" },
  839. shorthand: "github.com/example/docs",
  840. })
  841. expect(documents[0]?.info.plugins).toEqual([
  842. "opencode-helicone-session",
  843. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  844. ])
  845. }).pipe(Effect.provide(testLayer(tmp.path)))
  846. }),
  847. ),
  848. ),
  849. )
  850. it.live("migrates the deprecated reference key into references", () =>
  851. Effect.acquireRelease(
  852. Effect.promise(() => tmpdir()),
  853. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  854. ).pipe(
  855. Effect.flatMap((tmp) =>
  856. Effect.gen(function* () {
  857. yield* Effect.promise(() =>
  858. fs.writeFile(
  859. path.join(tmp.path, "opencode.json"),
  860. JSON.stringify({
  861. reference: {
  862. local: { path: "../library" },
  863. sdk: { repository: "github.com/example/sdk", branch: "main" },
  864. shorthand: "github.com/example/docs",
  865. },
  866. }),
  867. ),
  868. )
  869. return yield* Effect.gen(function* () {
  870. const config = yield* Config.Service
  871. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  872. expect(documents).toHaveLength(1)
  873. expect(documents[0]?.info.references).toEqual({
  874. local: { path: "../library" },
  875. sdk: { repository: "github.com/example/sdk", branch: "main" },
  876. shorthand: "github.com/example/docs",
  877. })
  878. }).pipe(Effect.provide(testLayer(tmp.path)))
  879. }),
  880. ),
  881. ),
  882. )
  883. it.live("migrates v1 configuration when a v1-only key is present", () =>
  884. Effect.acquireRelease(
  885. Effect.promise(() => tmpdir()),
  886. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  887. ).pipe(
  888. Effect.flatMap((tmp) =>
  889. Effect.gen(function* () {
  890. yield* Effect.promise(() =>
  891. fs.writeFile(
  892. path.join(tmp.path, "opencode.json"),
  893. JSON.stringify({
  894. shell: "/bin/zsh",
  895. default_agent: "reviewer",
  896. snapshot: false,
  897. autoshare: true,
  898. permission: {
  899. bash: "ask",
  900. edit: { "*.md": "allow", "*": "deny" },
  901. question: "deny",
  902. },
  903. agent: {
  904. reviewer: {
  905. prompt: "Review changes.",
  906. disable: true,
  907. temperature: 0.2,
  908. permission: { read: "allow" },
  909. },
  910. },
  911. plugin: [
  912. "opencode-helicone-session",
  913. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  914. ],
  915. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  916. references: {
  917. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  918. },
  919. attachment: { image: { auto_resize: false, max_width: 1200 } },
  920. provider: {
  921. custom: {
  922. options: { apiKey: "secret" },
  923. models: {
  924. model: {
  925. options: { reasoningEffort: "high" },
  926. variants: { fast: { temperature: 0.2 } },
  927. },
  928. },
  929. },
  930. openai: {
  931. npm: "@ai-sdk/openai",
  932. options: { apiKey: "secret", organization: "org" },
  933. models: {
  934. model: {
  935. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  936. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  937. },
  938. },
  939. },
  940. anthropic: {
  941. npm: "@ai-sdk/anthropic",
  942. models: {
  943. model: {
  944. options: {
  945. effort: "high",
  946. taskBudget: 4096,
  947. metadata: { userId: "user-1" },
  948. },
  949. },
  950. },
  951. },
  952. },
  953. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  954. experimental: { mcp_timeout: 5000 },
  955. mcp: {
  956. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  957. remote: {
  958. type: "remote",
  959. url: "https://mcp.example.com",
  960. oauth: { clientId: "client", callbackPort: 19876 },
  961. timeout: 20000,
  962. },
  963. },
  964. }),
  965. ),
  966. )
  967. return yield* Effect.gen(function* () {
  968. const config = yield* Config.Service
  969. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  970. expect(documents).toHaveLength(1)
  971. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  972. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  973. expect(documents[0]?.info.default_agent).toBe("reviewer")
  974. expect(documents[0]?.info.snapshots).toBe(false)
  975. expect(documents[0]?.info.share).toBe("auto")
  976. expect(documents[0]?.info.permissions).toEqual([
  977. { action: "shell", resource: "*", effect: "ask" },
  978. { action: "edit", resource: "*.md", effect: "allow" },
  979. { action: "edit", resource: "*", effect: "deny" },
  980. { action: "question", resource: "*", effect: "deny" },
  981. ])
  982. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  983. system: "Review changes.",
  984. disabled: true,
  985. request: { body: { temperature: 0.2 } },
  986. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  987. })
  988. expect(documents[0]?.info.plugins).toEqual([
  989. "opencode-helicone-session",
  990. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  991. ])
  992. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  993. expect(documents[0]?.info.references).toEqual({
  994. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  995. })
  996. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  997. expect(documents[0]?.info.providers?.custom).toMatchObject({
  998. settings: { apiKey: "secret" },
  999. models: {
  1000. model: {
  1001. settings: { reasoningEffort: "high" },
  1002. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  1003. },
  1004. },
  1005. })
  1006. expect(documents[0]?.info.providers?.openai).toMatchObject({
  1007. package: ProviderV2.aisdk("@ai-sdk/openai"),
  1008. settings: { apiKey: "secret", organization: "org" },
  1009. models: {
  1010. model: {
  1011. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1012. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  1013. },
  1014. },
  1015. })
  1016. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  1017. package: ProviderV2.aisdk("@ai-sdk/anthropic"),
  1018. models: {
  1019. model: {
  1020. settings: {
  1021. effort: "high",
  1022. taskBudget: 4096,
  1023. metadata: { userId: "user-1" },
  1024. },
  1025. },
  1026. },
  1027. })
  1028. expect(documents[0]?.info.compaction).toEqual({
  1029. auto: true,
  1030. keep: { tokens: 2000 },
  1031. buffer: 10000,
  1032. })
  1033. expect(documents[0]?.info.mcp).toMatchObject({
  1034. timeout: { catalog: 5000, execution: 5000 },
  1035. servers: {
  1036. local: {
  1037. type: "local",
  1038. command: ["node", "server.js"],
  1039. disabled: true,
  1040. timeout: { catalog: 10000, execution: 10000 },
  1041. },
  1042. remote: {
  1043. type: "remote",
  1044. url: "https://mcp.example.com",
  1045. oauth: { client_id: "client", callback_port: 19876 },
  1046. timeout: { catalog: 20000, execution: 20000 },
  1047. },
  1048. },
  1049. })
  1050. }).pipe(Effect.provide(testLayer(tmp.path)))
  1051. }),
  1052. ),
  1053. ),
  1054. )
  1055. it.live("ignores an invalid file while loading valid config values", () =>
  1056. Effect.acquireRelease(
  1057. Effect.promise(() => tmpdir()),
  1058. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1059. ).pipe(
  1060. Effect.flatMap((tmp) =>
  1061. Effect.gen(function* () {
  1062. yield* Effect.promise(() =>
  1063. Promise.all([
  1064. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1065. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1066. ]),
  1067. )
  1068. return yield* Effect.gen(function* () {
  1069. const config = yield* Config.Service
  1070. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1071. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1072. }).pipe(Effect.provide(testLayer(tmp.path)))
  1073. }),
  1074. ),
  1075. ),
  1076. )
  1077. it.live("loads global and ancestor configuration across the project boundary", () =>
  1078. Effect.acquireRelease(
  1079. Effect.promise(() => tmpdir()),
  1080. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1081. ).pipe(
  1082. Effect.flatMap((tmp) => {
  1083. const global = path.join(tmp.path, "global")
  1084. const root = path.join(tmp.path, "repo")
  1085. const parent = path.join(root, "packages")
  1086. const directory = path.join(parent, "app")
  1087. const globalAgents = path.join(global, "home", ".agents")
  1088. const globalClaude = path.join(global, "home", ".claude")
  1089. return Effect.gen(function* () {
  1090. yield* Effect.promise(async () => {
  1091. await fs.mkdir(global, { recursive: true })
  1092. await fs.mkdir(globalAgents, { recursive: true })
  1093. await fs.mkdir(globalClaude, { recursive: true })
  1094. await fs.mkdir(directory, { recursive: true })
  1095. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1096. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1097. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1098. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1099. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1100. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1101. await Promise.all([
  1102. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1103. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1104. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1105. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1106. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1107. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1108. fs.writeFile(
  1109. path.join(directory, ".opencode", "opencode.jsonc"),
  1110. JSON.stringify({ $schema: "directory-dot" }),
  1111. ),
  1112. ])
  1113. })
  1114. return yield* Effect.gen(function* () {
  1115. const config = yield* Config.Service
  1116. const entries = yield* config.entries()
  1117. const documents = entries.filter((entry) => entry.type === "document")
  1118. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1119. AbsolutePath.make(global),
  1120. AbsolutePath.make(path.join(root, ".opencode")),
  1121. AbsolutePath.make(path.join(directory, ".opencode")),
  1122. ])
  1123. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1124. AbsolutePath.make(globalAgents),
  1125. AbsolutePath.make(path.join(directory, ".agents")),
  1126. AbsolutePath.make(path.join(root, ".agents")),
  1127. ])
  1128. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1129. AbsolutePath.make(globalClaude),
  1130. AbsolutePath.make(path.join(directory, ".claude")),
  1131. AbsolutePath.make(path.join(root, ".claude")),
  1132. ])
  1133. expect(documents.map((document) => document.info.$schema)).toEqual([
  1134. "global",
  1135. "outside",
  1136. "root",
  1137. "parent",
  1138. "directory",
  1139. "root-dot",
  1140. "directory-dot",
  1141. ])
  1142. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1143. AbsolutePath.make(globalClaude),
  1144. AbsolutePath.make(path.join(directory, ".claude")),
  1145. AbsolutePath.make(path.join(root, ".claude")),
  1146. AbsolutePath.make(globalAgents),
  1147. AbsolutePath.make(path.join(directory, ".agents")),
  1148. AbsolutePath.make(path.join(root, ".agents")),
  1149. "global",
  1150. AbsolutePath.make(global),
  1151. "outside",
  1152. AbsolutePath.make(path.join(tmp.path, "opencode.json")),
  1153. "root",
  1154. AbsolutePath.make(path.join(root, "opencode.json")),
  1155. "parent",
  1156. AbsolutePath.make(path.join(parent, "opencode.jsonc")),
  1157. "directory",
  1158. AbsolutePath.make(path.join(directory, "opencode.json")),
  1159. "root-dot",
  1160. AbsolutePath.make(path.join(root, ".opencode")),
  1161. "directory-dot",
  1162. AbsolutePath.make(path.join(directory, ".opencode")),
  1163. ])
  1164. }).pipe(
  1165. Effect.provide(
  1166. testLayer(directory, global, root, {
  1167. type: "git",
  1168. store: AbsolutePath.make(path.join(root, ".git")),
  1169. }),
  1170. ),
  1171. )
  1172. })
  1173. }),
  1174. ),
  1175. )
  1176. })