config.test.ts 64 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
  8. import { ConfigModel } from "@opencode-ai/schema/config/model"
  9. import { ConfigProvider } from "@opencode-ai/schema/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 { Bus } from "@opencode-ai/core/bus"
  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 { Provider } 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 { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
  27. import { location } from "../fixture/location"
  28. import { tmpdir } from "../fixture/tmpdir"
  29. import { testEffect } from "../lib/effect"
  30. const it = testEffect(Layer.empty)
  31. const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
  32. function testLayer(
  33. directory: string,
  34. globalDirectory = path.join(directory, "global"),
  35. projectDirectory = directory,
  36. vcs?: Project.Vcs,
  37. watcher: Layer.Layer<Watcher.Service | Watcher.Test> = Watcher.testLayer,
  38. credentialNode = emptyCredentialNode,
  39. wellknownNode = emptyWellknownNode,
  40. options?: Config.Options,
  41. ) {
  42. const locationLayer = Layer.succeed(
  43. Location.Service,
  44. Location.Service.of(
  45. location(
  46. { directory: AbsolutePath.make(directory) },
  47. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  48. ),
  49. ),
  50. )
  51. const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  52. [Config.node, Config.configured(options)],
  53. [Location.node, locationLayer],
  54. [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
  55. [Credential.node, credentialNode],
  56. [WellKnown.node, wellknownNode],
  57. [Watcher.node, watcher],
  58. ])
  59. // Merge the watcher layer by reference so Watcher.Test resolves to the same
  60. // memoized instance the built graph uses.
  61. return Layer.mergeAll(built, watcher)
  62. }
  63. const provider = {
  64. package: "native",
  65. settings: {},
  66. headers: {},
  67. body: {},
  68. models: {},
  69. }
  70. describe("Config", () => {
  71. it.live("updates the first file-backed document", () =>
  72. Effect.acquireRelease(
  73. Effect.promise(() => tmpdir()),
  74. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  75. ).pipe(
  76. Effect.flatMap((tmp) => {
  77. const global = path.join(tmp.path, "global")
  78. const project = path.join(tmp.path, "project")
  79. const globalFile = path.join(global, "opencode.jsonc")
  80. const projectFile = path.join(project, "opencode.json")
  81. return Effect.promise(async () => {
  82. await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })])
  83. await Promise.all([
  84. fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'),
  85. fs.writeFile(projectFile, JSON.stringify({ shell: "project" })),
  86. ])
  87. }).pipe(
  88. Effect.andThen(
  89. Effect.gen(function* () {
  90. const config = yield* Config.Service
  91. const updated = yield* config.update((draft) => {
  92. draft.shell = "updated"
  93. })
  94. expect(updated.shell).toBe("updated")
  95. expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.")
  96. expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"')
  97. expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({
  98. shell: "project",
  99. })
  100. }).pipe(Effect.provide(testLayer(project, global))),
  101. ),
  102. )
  103. }),
  104. ),
  105. )
  106. it.effect("fails updates when no file-backed document exists", () =>
  107. Effect.gen(function* () {
  108. const config = yield* Config.Service
  109. const error = yield* config.update((draft) => void draft).pipe(Effect.flip)
  110. expect(error.message).toBe("No editable config document found")
  111. }).pipe(
  112. Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])),
  113. ),
  114. )
  115. it.live("loads explicit file and content overrides in priority order", () =>
  116. Effect.acquireRelease(
  117. Effect.promise(() => tmpdir()),
  118. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  119. ).pipe(
  120. Effect.flatMap((tmp) => {
  121. const global = path.join(tmp.path, "global")
  122. const project = path.join(tmp.path, "project")
  123. const explicit = path.join(tmp.path, "custom.json")
  124. return Effect.promise(async () => {
  125. await fs.mkdir(global, { recursive: true })
  126. await fs.mkdir(project, { recursive: true })
  127. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  128. await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
  129. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  130. }).pipe(
  131. Effect.andThen(
  132. Effect.gen(function* () {
  133. const config = yield* Config.Service
  134. const entries = yield* config.entries()
  135. expect(
  136. entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  137. ).toEqual(["global", "explicit", "project", "content"])
  138. expect(Config.latest(entries, "shell")).toBe("content")
  139. }).pipe(
  140. Effect.provide(
  141. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  142. file: explicit,
  143. content: JSON.stringify({ shell: "content" }),
  144. }),
  145. ),
  146. ),
  147. ),
  148. )
  149. }),
  150. ),
  151. )
  152. it.live("skips project configuration when project discovery is disabled", () =>
  153. Effect.acquireRelease(
  154. Effect.promise(() => tmpdir()),
  155. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  156. ).pipe(
  157. Effect.flatMap((tmp) => {
  158. const global = path.join(tmp.path, "global")
  159. const project = path.join(tmp.path, "project")
  160. return Effect.promise(async () => {
  161. await fs.mkdir(global, { recursive: true })
  162. await fs.mkdir(project, { recursive: true })
  163. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  164. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  165. }).pipe(
  166. Effect.andThen(
  167. Effect.gen(function* () {
  168. const config = yield* Config.Service
  169. expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
  170. }).pipe(
  171. Effect.provide(
  172. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  173. project: false,
  174. }),
  175. ),
  176. ),
  177. ),
  178. )
  179. }),
  180. ),
  181. )
  182. it.live("reloads external config and publishes directory updates", () =>
  183. Effect.acquireRelease(
  184. Effect.promise(() => tmpdir()),
  185. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  186. ).pipe(
  187. Effect.flatMap((tmp) =>
  188. Effect.gen(function* () {
  189. const global = path.join(tmp.path, "global")
  190. const project = path.join(tmp.path, "project")
  191. const file = path.join(global, "opencode.json")
  192. yield* Effect.promise(async () => {
  193. await fs.mkdir(global, { recursive: true })
  194. await fs.mkdir(project, { recursive: true })
  195. await fs.writeFile(file, JSON.stringify({ shell: "first" }))
  196. })
  197. return yield* Effect.gen(function* () {
  198. const config = yield* Config.Service
  199. const bus = yield* Bus.Service
  200. const watcher = yield* Watcher.Test
  201. const changed = yield* bus
  202. .subscribe(Event.Updated)
  203. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  204. yield* Effect.sleep("10 millis")
  205. yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
  206. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
  207. yield* watcher.emit({ type: "update", path: file })
  208. expect(yield* Fiber.join(changed)).toHaveLength(1)
  209. expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
  210. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  211. }),
  212. ),
  213. ),
  214. )
  215. it.live("exposes filesystem updates under config roots through changes", () =>
  216. Effect.acquireRelease(
  217. Effect.promise(() => tmpdir()),
  218. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  219. ).pipe(
  220. Effect.flatMap((tmp) =>
  221. Effect.gen(function* () {
  222. const global = path.join(tmp.path, "global")
  223. const project = path.join(tmp.path, "project")
  224. yield* Effect.promise(async () => {
  225. await fs.mkdir(path.join(global, "commands"), { recursive: true })
  226. await fs.mkdir(project, { recursive: true })
  227. })
  228. return yield* Effect.gen(function* () {
  229. const config = yield* Config.Service
  230. const watcher = yield* Watcher.Test
  231. const received = yield* config
  232. .changes()
  233. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  234. yield* Effect.sleep("10 millis")
  235. const file = path.join(global, "commands", "review.md")
  236. yield* watcher.emit({ type: "update", path: file })
  237. const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
  238. expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
  239. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  240. }),
  241. ),
  242. ),
  243. )
  244. // Real watcher on purpose: the regression this pins (a deleted config file's
  245. // watch being torn down, making recreation invisible) only reproduces with
  246. // path-faithful event delivery.
  247. it.live("keeps watching a deleted config file so recreating it reloads", () =>
  248. Effect.acquireRelease(
  249. Effect.promise(() => tmpdir()),
  250. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  251. ).pipe(
  252. Effect.flatMap((tmp) =>
  253. Effect.gen(function* () {
  254. const global = path.join(tmp.path, "global")
  255. const project = path.join(tmp.path, "project")
  256. const file = path.join(project, "opencode.json")
  257. yield* Effect.promise(async () => {
  258. await fs.mkdir(global, { recursive: true })
  259. await fs.mkdir(project, { recursive: true })
  260. await fs.writeFile(file, JSON.stringify({ shell: "one" }))
  261. })
  262. return yield* Effect.gen(function* () {
  263. const config = yield* Config.Service
  264. const bus = yield* Bus.Service
  265. expect(Config.latest(yield* config.entries(), "shell")).toBe("one")
  266. yield* Effect.sleep("10 millis")
  267. const removed = yield* bus
  268. .subscribe(Event.Updated)
  269. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  270. yield* Effect.promise(() => fs.rm(file))
  271. yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
  272. expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
  273. const recreated = yield* bus
  274. .subscribe(Event.Updated)
  275. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  276. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
  277. yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
  278. expect(Config.latest(yield* config.entries(), "shell")).toBe("two")
  279. }).pipe(
  280. Effect.provide(
  281. AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  282. [
  283. Location.node,
  284. Layer.succeed(
  285. Location.Service,
  286. Location.Service.of(location({ directory: AbsolutePath.make(project) })),
  287. ),
  288. ],
  289. [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
  290. [Credential.node, emptyCredentialNode],
  291. [WellKnown.node, emptyWellknownNode],
  292. ]),
  293. ),
  294. )
  295. }),
  296. ),
  297. ),
  298. )
  299. it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
  300. Effect.gen(function* () {
  301. const config = yield* Config.Service
  302. const test = yield* Config.Test
  303. expect(yield* config.entries()).toEqual([])
  304. const entry = new Document({ type: "document", info: new Info({}) })
  305. yield* test.setEntries([entry])
  306. expect(yield* config.entries()).toEqual([entry])
  307. const received = yield* config
  308. .changes()
  309. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  310. yield* Effect.yieldNow
  311. yield* test.emitChange({ type: "create", path: "/root/commands/review.md" })
  312. expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "create", path: "/root/commands/review.md" }])
  313. }).pipe(Effect.provide(Config.testLayer())),
  314. )
  315. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  316. Effect.sync(() => {
  317. const entries = [
  318. new Document({
  319. type: "document",
  320. info: new Info({ model: selection("openrouter/openai/gpt-5") }),
  321. }),
  322. new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  323. new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
  324. new Document({ type: "document", info: new Info({}) }),
  325. new Document({
  326. type: "document",
  327. info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
  328. }),
  329. ]
  330. expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
  331. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  332. }),
  333. )
  334. it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
  335. Effect.acquireUseRelease(
  336. Effect.promise(() => tmpdir()),
  337. (tmp) =>
  338. Effect.gen(function* () {
  339. const global = path.join(tmp.path, "global")
  340. const project = path.join(tmp.path, "project")
  341. yield* Effect.promise(async () => {
  342. await fs.mkdir(global, { recursive: true })
  343. await fs.mkdir(project, { recursive: true })
  344. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  345. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  346. })
  347. const integrationID = Integration.ID.make("https://example.com")
  348. let available = false
  349. let key = "secret"
  350. const credentialNode = makeGlobalNode({
  351. service: Credential.Service,
  352. layer: Layer.succeed(
  353. Credential.Service,
  354. Credential.Service.of({
  355. all: () => Effect.die("unused Credential.all"),
  356. list: () =>
  357. Effect.succeed([
  358. new Credential.Info({
  359. id: Credential.ID.create(),
  360. integrationID,
  361. label: "default",
  362. value: Credential.Key.make({ type: "key", key }),
  363. }),
  364. ]),
  365. get: () => Effect.die("unused Credential.get"),
  366. create: () => Effect.die("unused Credential.create"),
  367. update: () => Effect.die("unused Credential.update"),
  368. remove: () => Effect.die("unused Credential.remove"),
  369. }),
  370. ),
  371. deps: [],
  372. })
  373. const entry: WellKnown.Entry = {
  374. origin: "https://example.com",
  375. integrationID,
  376. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  377. }
  378. const wellknownNode = makeGlobalNode({
  379. service: WellKnown.Service,
  380. layer: Layer.succeed(
  381. WellKnown.Service,
  382. WellKnown.Service.of({
  383. entries: () => Effect.succeed([entry]),
  384. snapshot: () => [entry],
  385. refresh: () => Effect.succeed(false),
  386. add: () => Effect.die("unused Wellknown.add"),
  387. remove: () => Effect.die("unused Wellknown.remove"),
  388. resolve: (_entry, variables) =>
  389. available
  390. ? Effect.succeed([{ shell: variables.TOKEN }])
  391. : Effect.fail(new Error("expired credential")),
  392. }),
  393. ),
  394. deps: [],
  395. })
  396. return yield* Effect.gen(function* () {
  397. const config = yield* Config.Service
  398. const bus = yield* Bus.Service
  399. const initial = yield* config.entries()
  400. expect(Config.latest(initial, "shell")).toBe("project")
  401. expect(
  402. initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  403. ).toEqual(["global", "project"])
  404. const updated = yield* bus
  405. .subscribe(Event.Updated)
  406. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  407. yield* Effect.yieldNow
  408. available = true
  409. key = "next"
  410. yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
  411. expect(yield* Fiber.join(updated)).toHaveLength(1)
  412. const refreshed = yield* config.entries()
  413. expect(Config.latest(refreshed, "shell")).toBe("project")
  414. expect(
  415. refreshed.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  416. ).toEqual(["next", "global", "project"])
  417. }).pipe(
  418. Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
  419. )
  420. }),
  421. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  422. ),
  423. )
  424. it.live("logs redacted source-aware diagnostics for every config source", () => {
  425. const output: Array<Record<string, unknown>> = []
  426. const logger = Logger.map(Logger.formatStructured, (entry) => {
  427. if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
  428. const details = entry.message[1]
  429. if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
  430. })
  431. return Effect.acquireUseRelease(
  432. Effect.promise(() => tmpdir()),
  433. (tmp) =>
  434. Effect.gen(function* () {
  435. const global = path.join(tmp.path, "global")
  436. const project = path.join(tmp.path, "project")
  437. const malformed = path.join(tmp.path, "malformed.json")
  438. yield* Effect.promise(async () => {
  439. await fs.mkdir(global, { recursive: true })
  440. await fs.mkdir(project, { recursive: true })
  441. await fs.writeFile(path.join(global, "opencode.json"), "null")
  442. await fs.writeFile(path.join(project, "opencode.json"), "")
  443. await fs.writeFile(malformed, '{ "credential": "file-secret"')
  444. })
  445. const integrationID = Integration.ID.make("https://invalid.example.com")
  446. const entry: WellKnown.Entry = {
  447. origin: "https://invalid.example.com",
  448. integrationID,
  449. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  450. }
  451. const credentialNode = makeGlobalNode({
  452. service: Credential.Service,
  453. layer: Layer.succeed(
  454. Credential.Service,
  455. Credential.Service.of({
  456. all: () => Effect.die("unused Credential.all"),
  457. list: () =>
  458. Effect.succeed([
  459. new Credential.Info({
  460. id: Credential.ID.create(),
  461. integrationID,
  462. label: "default",
  463. value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
  464. }),
  465. ]),
  466. get: () => Effect.die("unused Credential.get"),
  467. create: () => Effect.die("unused Credential.create"),
  468. update: () => Effect.die("unused Credential.update"),
  469. remove: () => Effect.die("unused Credential.remove"),
  470. }),
  471. ),
  472. deps: [],
  473. })
  474. const wellknownNode = makeGlobalNode({
  475. service: WellKnown.Service,
  476. layer: Layer.succeed(
  477. WellKnown.Service,
  478. WellKnown.Service.of({
  479. entries: () => Effect.succeed([entry]),
  480. snapshot: () => [entry],
  481. refresh: () => Effect.succeed(false),
  482. add: () => Effect.die("unused Wellknown.add"),
  483. remove: () => Effect.die("unused Wellknown.remove"),
  484. // Exercise the loader boundary against a malformed implementation response.
  485. resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
  486. }),
  487. ),
  488. deps: [],
  489. })
  490. yield* Config.Service.use((config) => config.entries()).pipe(
  491. Effect.provide(
  492. testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
  493. file: malformed,
  494. content: "",
  495. }),
  496. ),
  497. )
  498. expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
  499. [
  500. `${path.join(global, "opencode.json")}:$:invalid`,
  501. `${path.join(project, "opencode.json")}:$:invalid`,
  502. `${malformed}:$:invalid`,
  503. "https://invalid.example.com:$:invalid",
  504. "OPENCODE_CONFIG_CONTENT:$:invalid",
  505. ].toSorted(),
  506. )
  507. expect(JSON.stringify(output)).not.toContain("secret")
  508. }),
  509. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  510. ).pipe(Effect.provide(Logger.layer([logger])))
  511. })
  512. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  513. Effect.sync(() => {
  514. FastCheck.assert(
  515. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  516. const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
  517. Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
  518. Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
  519. ),
  520. )
  521. Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
  522. }),
  523. { numRuns: 100 },
  524. )
  525. }),
  526. )
  527. it.effect("migrates the v1 experimental subagent depth", () =>
  528. Effect.sync(() => {
  529. expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
  530. }),
  531. )
  532. it.effect("migrates the v1 small model to the title agent", () =>
  533. Effect.sync(() => {
  534. expect(
  535. ConfigMigrateV1.migrate({
  536. small_model: "anthropic/claude-haiku-4-5",
  537. agent: { title: { prompt: "Custom title prompt" } },
  538. }).agents?.title,
  539. ).toEqual({
  540. model: { providerID: "anthropic", model: "claude-haiku-4-5" },
  541. system: "Custom title prompt",
  542. })
  543. }),
  544. )
  545. it.effect("migrates v1 provider lists to policies", () =>
  546. Effect.sync(() => {
  547. expect(
  548. ConfigMigrateV1.migrate({
  549. enabled_providers: ["anthropic", "openai"],
  550. disabled_providers: ["openai"],
  551. }).experimental?.policies,
  552. ).toEqual([
  553. { action: "provider.use", resource: "*", effect: "deny" },
  554. { action: "provider.use", resource: "anthropic", effect: "allow" },
  555. { action: "provider.use", resource: "openai", effect: "allow" },
  556. { action: "provider.use", resource: "openai", effect: "deny" },
  557. ])
  558. expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
  559. { action: "provider.use", resource: "*", effect: "deny" },
  560. ])
  561. }),
  562. )
  563. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  564. Effect.sync(() => {
  565. const migrated = ConfigMigrateV1.migrate({
  566. provider: {
  567. bedrock: {
  568. npm: "@ai-sdk/amazon-bedrock",
  569. options: {
  570. headers: { "x-test": "1" },
  571. body: { trace: true },
  572. region: "us-east-1",
  573. profile: "dev",
  574. },
  575. },
  576. },
  577. })
  578. expect(migrated.providers?.bedrock).toMatchObject({
  579. package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
  580. settings: { region: "us-east-1", profile: "dev" },
  581. headers: { "x-test": "1" },
  582. body: { trace: true },
  583. })
  584. }),
  585. )
  586. it.effect("renames old provider IDs while migrating v1 configuration", () =>
  587. Effect.sync(() => {
  588. const migrated = ConfigMigrateV1.migrate({
  589. model: "azure-cognitive-services/deployment",
  590. enabled_providers: ["google-vertex-anthropic"],
  591. disabled_providers: ["azure-cognitive-services"],
  592. agent: {
  593. reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
  594. },
  595. command: {
  596. review: { template: "Review", model: "azure-cognitive-services/deployment" },
  597. },
  598. provider: {
  599. "azure-cognitive-services": {
  600. npm: "@ai-sdk/azure",
  601. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  602. models: { deployment: {} },
  603. },
  604. "google-vertex-anthropic": {
  605. npm: "@ai-sdk/google-vertex/anthropic",
  606. options: { project: "test-project", location: "us-central1" },
  607. models: { "claude-sonnet": {} },
  608. },
  609. },
  610. })
  611. expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
  612. expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
  613. expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
  614. expect(migrated.experimental?.policies).toEqual([
  615. { action: "provider.use", resource: "*", effect: "deny" },
  616. { action: "provider.use", resource: "google-vertex", effect: "allow" },
  617. { action: "provider.use", resource: "azure", effect: "deny" },
  618. ])
  619. expect(migrated.providers?.azure).toMatchObject({
  620. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  621. package: Provider.aisdk("@ai-sdk/azure"),
  622. models: { deployment: {} },
  623. })
  624. expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
  625. expect(migrated.providers?.["google-vertex"]).toMatchObject({
  626. settings: { project: "test-project", location: "us-central1" },
  627. models: {
  628. "claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
  629. },
  630. })
  631. expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
  632. expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
  633. }),
  634. )
  635. it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
  636. Effect.sync(() => {
  637. const migrated = ConfigMigrateV1.migrate({
  638. provider: {
  639. "azure-cognitive-services": {
  640. npm: "@ai-sdk/openai-compatible",
  641. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  642. },
  643. },
  644. })
  645. expect(migrated.providers?.azure).toMatchObject({
  646. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  647. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  648. settings: {
  649. baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
  650. },
  651. })
  652. }),
  653. )
  654. it.effect("ignores old provider IDs when the current provider ID is configured", () =>
  655. Effect.sync(() => {
  656. const migrated = ConfigMigrateV1.migrate({
  657. provider: {
  658. azure: { models: { current: {} } },
  659. "azure-cognitive-services": { models: { legacy: {} } },
  660. "google-vertex": { models: { gemini: {} } },
  661. "google-vertex-anthropic": { models: { claude: {} } },
  662. },
  663. })
  664. expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
  665. expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
  666. }),
  667. )
  668. it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
  669. Effect.sync(() => {
  670. const migrated = ConfigMigrateV1.migrate({
  671. provider: {
  672. "google-vertex-anthropic": {
  673. models: { claude: {} },
  674. },
  675. },
  676. })
  677. expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
  678. expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
  679. Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
  680. )
  681. }),
  682. )
  683. it.effect("migrates v1 interleaved fields to compatibility", () =>
  684. Effect.sync(() => {
  685. const migrated = ConfigMigrateV1.migrate({
  686. provider: {
  687. custom: {
  688. models: {
  689. object: { interleaved: { field: "vendor_reasoning" } },
  690. string: { interleaved: "reasoning_text" },
  691. boolean: { interleaved: true },
  692. },
  693. },
  694. },
  695. })
  696. expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
  697. reasoningField: "vendor_reasoning",
  698. })
  699. expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
  700. expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
  701. }),
  702. )
  703. it.effect("migrates v1 command configuration", () =>
  704. Effect.sync(() => {
  705. expect(
  706. ConfigMigrateV1.migrate({
  707. command: {
  708. review: {
  709. template: "Review changes",
  710. description: "Review code",
  711. agent: "reviewer",
  712. model: "anthropic/claude",
  713. variant: "high",
  714. subtask: true,
  715. },
  716. },
  717. }).commands,
  718. ).toEqual({
  719. review: {
  720. template: "Review changes",
  721. description: "Review code",
  722. agent: "reviewer",
  723. model: { providerID: "anthropic", model: "claude", variant: "high" },
  724. subtask: true,
  725. },
  726. })
  727. }),
  728. )
  729. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  730. Effect.sync(() => {
  731. expect(
  732. ConfigMigrateV1.migrate({
  733. permission: {
  734. task: "ask",
  735. bash: { "git status": "allow", "*": "deny" },
  736. write: "deny",
  737. read: "allow",
  738. },
  739. }).permissions,
  740. ).toEqual([
  741. { action: "subagent", resource: "*", effect: "ask" },
  742. { action: "shell", resource: "git status", effect: "allow" },
  743. { action: "shell", resource: "*", effect: "deny" },
  744. { action: "edit", resource: "*", effect: "deny" },
  745. { action: "read", resource: "*", effect: "allow" },
  746. ])
  747. }),
  748. )
  749. it.live("returns an empty configuration when directory files do not exist", () =>
  750. Effect.acquireRelease(
  751. Effect.promise(() => tmpdir()),
  752. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  753. ).pipe(
  754. Effect.flatMap((tmp) =>
  755. Effect.gen(function* () {
  756. const config = yield* Config.Service
  757. const entries = yield* config.entries()
  758. expect(entries).toEqual([
  759. new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  760. ])
  761. }).pipe(Effect.provide(testLayer(tmp.path))),
  762. ),
  763. ),
  764. )
  765. it.live("deduplicates global ecosystem directories found during upward discovery", () =>
  766. Effect.acquireRelease(
  767. Effect.promise(() => tmpdir()),
  768. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  769. ).pipe(
  770. Effect.flatMap((tmp) =>
  771. Effect.gen(function* () {
  772. const global = path.join(tmp.path, "global")
  773. const home = path.join(global, "home")
  774. const project = path.join(home, "project")
  775. yield* Effect.promise(() =>
  776. Promise.all([
  777. fs.mkdir(path.join(home, ".claude"), { recursive: true }),
  778. fs.mkdir(path.join(home, ".agents"), { recursive: true }),
  779. fs.mkdir(project, { recursive: true }),
  780. ]),
  781. )
  782. const entries = yield* Config.Service.use((config) => config.entries()).pipe(
  783. Effect.provide(testLayer(project, global)),
  784. )
  785. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  786. AbsolutePath.make(path.join(home, ".claude")),
  787. ])
  788. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  789. AbsolutePath.make(path.join(home, ".agents")),
  790. ])
  791. }),
  792. ),
  793. ),
  794. )
  795. it.live("does not watch ecosystem config roots", () =>
  796. Effect.acquireRelease(
  797. Effect.promise(() => tmpdir()),
  798. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  799. ).pipe(
  800. Effect.flatMap((tmp) =>
  801. Effect.gen(function* () {
  802. yield* Effect.promise(() =>
  803. Promise.all([
  804. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  805. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  806. ]),
  807. )
  808. return yield* Effect.gen(function* () {
  809. const config = yield* Config.Service
  810. const watcher = yield* Watcher.Test
  811. yield* config.entries()
  812. expect(yield* watcher.subscriptions()).toEqual([
  813. {
  814. type: "directory",
  815. path: AbsolutePath.make(path.join(tmp.path, "global")),
  816. ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
  817. },
  818. ])
  819. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
  820. }),
  821. ),
  822. ),
  823. )
  824. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  825. Effect.acquireRelease(
  826. Effect.promise(() => tmpdir()),
  827. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  828. ).pipe(
  829. Effect.flatMap((tmp) =>
  830. Effect.gen(function* () {
  831. yield* Effect.promise(() =>
  832. Promise.all([
  833. fs.writeFile(
  834. path.join(tmp.path, "opencode.json"),
  835. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  836. ),
  837. fs.writeFile(
  838. path.join(tmp.path, "opencode.jsonc"),
  839. `{
  840. // Later global files override scalar fields while retaining providers.
  841. "$schema": "last",
  842. "providers": { "last": ${JSON.stringify(provider)} },
  843. }`,
  844. ),
  845. ]),
  846. )
  847. return yield* Effect.gen(function* () {
  848. const config = yield* Config.Service
  849. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  850. expect(documents).toHaveLength(2)
  851. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  852. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  853. expect(documents[0]).toBeInstanceOf(Document)
  854. expect(documents[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "opencode.json")))
  855. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  856. yield* Effect.promise(() =>
  857. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  858. )
  859. expect(
  860. (yield* config.entries())
  861. .filter((entry) => entry.type === "document")
  862. .map((document) => document.info.$schema),
  863. ).toEqual(["base", "last"])
  864. }).pipe(Effect.provide(testLayer(tmp.path)))
  865. }),
  866. ),
  867. ),
  868. )
  869. it.live("substitutes environment variables and relative file contents", () =>
  870. Effect.acquireUseRelease(
  871. Effect.sync(() => {
  872. const previous = {
  873. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  874. missing: process.env.OPENCODE_TEST_MISSING,
  875. }
  876. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  877. delete process.env.OPENCODE_TEST_MISSING
  878. return previous
  879. }),
  880. () =>
  881. Effect.acquireUseRelease(
  882. Effect.promise(() => tmpdir()),
  883. (tmp) =>
  884. Effect.gen(function* () {
  885. yield* Effect.promise(() =>
  886. Promise.all([
  887. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  888. fs.writeFile(
  889. path.join(tmp.path, "opencode.jsonc"),
  890. `{
  891. // Ignored reference: {file:missing.txt}
  892. "username": "user-{env:OPENCODE_TEST_MISSING}",
  893. "mcp": {
  894. "servers": {
  895. "remote": {
  896. "type": "remote",
  897. "url": "https://example.com/mcp",
  898. "headers": {
  899. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  900. "X-Token": "{file:token.txt}"
  901. }
  902. }
  903. }
  904. }
  905. }`,
  906. ),
  907. ]),
  908. )
  909. return yield* Effect.gen(function* () {
  910. const config = yield* Config.Service
  911. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  912. expect(document?.info.username).toBe("user-")
  913. const remote = document?.info.mcp?.servers?.remote
  914. expect(remote?.type).toBe("remote")
  915. if (remote?.type !== "remote") return
  916. expect(remote.headers).toEqual({
  917. Authorization: "Bearer secret",
  918. "X-Token": 'file\n"token"',
  919. })
  920. }).pipe(Effect.provide(testLayer(tmp.path)))
  921. }),
  922. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  923. ),
  924. (previous) =>
  925. Effect.sync(() => {
  926. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  927. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  928. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  929. else process.env.OPENCODE_TEST_MISSING = previous.missing
  930. }),
  931. ),
  932. )
  933. it.live("does not load legacy config.json files", () =>
  934. Effect.acquireRelease(
  935. Effect.promise(() => tmpdir()),
  936. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  937. ).pipe(
  938. Effect.flatMap((tmp) =>
  939. Effect.gen(function* () {
  940. yield* Effect.promise(() =>
  941. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  942. )
  943. return yield* Effect.gen(function* () {
  944. const config = yield* Config.Service
  945. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  946. expect(documents).toHaveLength(0)
  947. }).pipe(Effect.provide(testLayer(tmp.path)))
  948. }),
  949. ),
  950. ),
  951. )
  952. it.live("accepts $schema metadata without writing it into config files", () =>
  953. Effect.acquireRelease(
  954. Effect.promise(() => tmpdir()),
  955. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  956. ).pipe(
  957. Effect.flatMap((tmp) =>
  958. Effect.gen(function* () {
  959. const file = path.join(tmp.path, "opencode.json")
  960. const contents = JSON.stringify({
  961. shell: "/bin/zsh",
  962. providers: { local: provider },
  963. })
  964. yield* Effect.promise(() => fs.writeFile(file, contents))
  965. return yield* Effect.gen(function* () {
  966. const config = yield* Config.Service
  967. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  968. expect(documents[0]?.info.$schema).toBeUndefined()
  969. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  970. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  971. }).pipe(Effect.provide(testLayer(tmp.path)))
  972. }),
  973. ),
  974. ),
  975. )
  976. it.live("loads supported scalar and resource configuration", () =>
  977. Effect.acquireRelease(
  978. Effect.promise(() => tmpdir()),
  979. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  980. ).pipe(
  981. Effect.flatMap((tmp) =>
  982. Effect.gen(function* () {
  983. yield* Effect.promise(() =>
  984. fs.writeFile(
  985. path.join(tmp.path, "opencode.json"),
  986. JSON.stringify({
  987. shell: "/bin/bash",
  988. model: "anthropic/claude",
  989. default_agent: "reviewer",
  990. autoupdate: "notify",
  991. share: "disabled",
  992. enterprise: { url: "https://share.example.com" },
  993. username: "test-user",
  994. permissions: [
  995. { action: "bash", resource: "*", effect: "ask" },
  996. { action: "bash", resource: "git status", effect: "allow" },
  997. ],
  998. agents: {
  999. reviewer: {
  1000. model: "openrouter/openai/gpt-5#high",
  1001. request: {
  1002. headers: { "x-agent": "reviewer" },
  1003. body: { reasoningEffort: "high" },
  1004. },
  1005. description: "Review changes for correctness",
  1006. system: "Find regressions.",
  1007. mode: "subagent",
  1008. hidden: false,
  1009. color: "#ff6b6b",
  1010. steps: 12,
  1011. disabled: false,
  1012. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  1013. },
  1014. },
  1015. snapshots: false,
  1016. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  1017. formatter: {
  1018. prettier: { disabled: true },
  1019. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  1020. },
  1021. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  1022. media: {
  1023. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  1024. },
  1025. tool_output: { max_lines: 1000, max_bytes: 32768 },
  1026. mcp: {
  1027. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  1028. servers: {
  1029. local: {
  1030. type: "local",
  1031. command: ["node", "./mcp/server.js"],
  1032. environment: { API_KEY: "secret" },
  1033. disabled: false,
  1034. codemode: false,
  1035. timeout: { catalog: 10000 },
  1036. },
  1037. remote: {
  1038. type: "remote",
  1039. url: "https://mcp.example.com/mcp",
  1040. headers: { Authorization: "Bearer token" },
  1041. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  1042. disabled: true,
  1043. codemode: false,
  1044. timeout: { startup: 15000 },
  1045. },
  1046. },
  1047. },
  1048. compaction: {
  1049. auto: true,
  1050. prune: false,
  1051. keep: { tokens: 2000 },
  1052. buffer: 10000,
  1053. },
  1054. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  1055. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  1056. references: {
  1057. local: { path: "../library" },
  1058. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1059. shorthand: "github.com/example/docs",
  1060. },
  1061. plugins: [
  1062. "opencode-helicone-session",
  1063. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1064. ],
  1065. }),
  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).toHaveLength(1)
  1072. expect(documents[0]?.info.shell).toBe("/bin/bash")
  1073. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  1074. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1075. expect(documents[0]?.info.autoupdate).toBe("notify")
  1076. expect(documents[0]?.info.share).toBe("disabled")
  1077. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  1078. expect(documents[0]?.info.username).toBe("test-user")
  1079. expect(documents[0]?.info.permissions).toEqual([
  1080. { action: "bash", resource: "*", effect: "ask" },
  1081. { action: "bash", resource: "git status", effect: "allow" },
  1082. ])
  1083. const reviewer = documents[0]?.info.agents?.reviewer
  1084. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  1085. expect(reviewer?.request).toEqual({
  1086. headers: { "x-agent": "reviewer" },
  1087. body: { reasoningEffort: "high" },
  1088. })
  1089. expect(reviewer?.description).toBe("Review changes for correctness")
  1090. expect(reviewer?.system).toBe("Find regressions.")
  1091. expect(reviewer?.mode).toBe("subagent")
  1092. expect(reviewer?.hidden).toBe(false)
  1093. expect(reviewer?.color).toBe("#ff6b6b")
  1094. expect(reviewer?.steps).toBe(12)
  1095. expect(reviewer?.disabled).toBe(false)
  1096. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  1097. expect(documents[0]?.info.snapshots).toBe(false)
  1098. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  1099. expect(documents[0]?.info.formatter).toEqual({
  1100. prettier: { disabled: true },
  1101. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  1102. })
  1103. expect(documents[0]?.info.lsp).toEqual({
  1104. typescript: { disabled: true },
  1105. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  1106. })
  1107. expect(documents[0]?.info.media).toEqual({
  1108. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  1109. })
  1110. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  1111. expect(documents[0]?.info.mcp).toEqual({
  1112. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  1113. servers: {
  1114. local: {
  1115. type: "local",
  1116. command: ["node", "./mcp/server.js"],
  1117. environment: { API_KEY: "secret" },
  1118. disabled: false,
  1119. codemode: false,
  1120. timeout: { catalog: 10000 },
  1121. },
  1122. remote: {
  1123. type: "remote",
  1124. url: "https://mcp.example.com/mcp",
  1125. headers: { Authorization: "Bearer token" },
  1126. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  1127. disabled: true,
  1128. codemode: false,
  1129. timeout: { startup: 15000 },
  1130. },
  1131. },
  1132. })
  1133. expect(documents[0]?.info.compaction).toEqual({
  1134. auto: true,
  1135. keep: { tokens: 2000 },
  1136. buffer: 10000,
  1137. })
  1138. expect(documents[0]?.info.skills).toEqual([
  1139. "./skills",
  1140. "~/shared-skills",
  1141. "https://example.com/.well-known/skills/",
  1142. ])
  1143. expect(documents[0]?.info.instructions).toEqual([
  1144. "CONTRIBUTING.md",
  1145. ".cursor/rules/*.md",
  1146. "https://example.com/shared-rules.md",
  1147. ])
  1148. expect(documents[0]?.info.references).toEqual({
  1149. local: { path: "../library" },
  1150. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1151. shorthand: "github.com/example/docs",
  1152. })
  1153. expect(documents[0]?.info.plugins).toEqual([
  1154. "opencode-helicone-session",
  1155. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1156. ])
  1157. }).pipe(Effect.provide(testLayer(tmp.path)))
  1158. }),
  1159. ),
  1160. ),
  1161. )
  1162. it.live("migrates the deprecated reference key into references", () =>
  1163. Effect.acquireRelease(
  1164. Effect.promise(() => tmpdir()),
  1165. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1166. ).pipe(
  1167. Effect.flatMap((tmp) =>
  1168. Effect.gen(function* () {
  1169. yield* Effect.promise(() =>
  1170. fs.writeFile(
  1171. path.join(tmp.path, "opencode.json"),
  1172. JSON.stringify({
  1173. reference: {
  1174. local: { path: "../library" },
  1175. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1176. shorthand: "github.com/example/docs",
  1177. },
  1178. }),
  1179. ),
  1180. )
  1181. return yield* Effect.gen(function* () {
  1182. const config = yield* Config.Service
  1183. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1184. expect(documents).toHaveLength(1)
  1185. expect(documents[0]?.info.references).toEqual({
  1186. local: { path: "../library" },
  1187. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1188. shorthand: "github.com/example/docs",
  1189. })
  1190. }).pipe(Effect.provide(testLayer(tmp.path)))
  1191. }),
  1192. ),
  1193. ),
  1194. )
  1195. it.live("migrates v1 configuration when a v1-only key is present", () =>
  1196. Effect.acquireRelease(
  1197. Effect.promise(() => tmpdir()),
  1198. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1199. ).pipe(
  1200. Effect.flatMap((tmp) =>
  1201. Effect.gen(function* () {
  1202. yield* Effect.promise(() =>
  1203. fs.writeFile(
  1204. path.join(tmp.path, "opencode.json"),
  1205. JSON.stringify({
  1206. shell: "/bin/zsh",
  1207. default_agent: "reviewer",
  1208. snapshot: false,
  1209. autoshare: true,
  1210. permission: {
  1211. bash: "ask",
  1212. edit: { "*.md": "allow", "*": "deny" },
  1213. question: "deny",
  1214. },
  1215. agent: {
  1216. reviewer: {
  1217. prompt: "Review changes.",
  1218. disable: true,
  1219. temperature: 0.2,
  1220. permission: { read: "allow" },
  1221. },
  1222. },
  1223. plugin: [
  1224. "opencode-helicone-session",
  1225. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  1226. ],
  1227. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  1228. references: {
  1229. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1230. },
  1231. attachment: { image: { auto_resize: false, max_width: 1200 } },
  1232. provider: {
  1233. custom: {
  1234. options: { apiKey: "secret" },
  1235. models: {
  1236. model: {
  1237. options: { reasoningEffort: "high" },
  1238. variants: { fast: { temperature: 0.2 } },
  1239. },
  1240. },
  1241. },
  1242. openai: {
  1243. npm: "@ai-sdk/openai",
  1244. options: { apiKey: "secret", organization: "org" },
  1245. models: {
  1246. model: {
  1247. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1248. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  1249. },
  1250. },
  1251. },
  1252. anthropic: {
  1253. npm: "@ai-sdk/anthropic",
  1254. models: {
  1255. model: {
  1256. options: {
  1257. effort: "high",
  1258. taskBudget: 4096,
  1259. metadata: { userId: "user-1" },
  1260. },
  1261. },
  1262. },
  1263. },
  1264. },
  1265. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  1266. experimental: { mcp_timeout: 5000 },
  1267. mcp: {
  1268. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  1269. remote: {
  1270. type: "remote",
  1271. url: "https://mcp.example.com",
  1272. oauth: { clientId: "client", callbackPort: 19876 },
  1273. timeout: 20000,
  1274. },
  1275. },
  1276. }),
  1277. ),
  1278. )
  1279. return yield* Effect.gen(function* () {
  1280. const config = yield* Config.Service
  1281. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1282. expect(documents).toHaveLength(1)
  1283. expect(documents[0]?.info).toBeInstanceOf(Info)
  1284. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  1285. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1286. expect(documents[0]?.info.snapshots).toBe(false)
  1287. expect(documents[0]?.info.share).toBe("auto")
  1288. expect(documents[0]?.info.permissions).toEqual([
  1289. { action: "shell", resource: "*", effect: "ask" },
  1290. { action: "edit", resource: "*.md", effect: "allow" },
  1291. { action: "edit", resource: "*", effect: "deny" },
  1292. { action: "question", resource: "*", effect: "deny" },
  1293. ])
  1294. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  1295. system: "Review changes.",
  1296. disabled: true,
  1297. request: { body: { temperature: 0.2 } },
  1298. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  1299. })
  1300. expect(documents[0]?.info.plugins).toEqual([
  1301. "opencode-helicone-session",
  1302. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1303. ])
  1304. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  1305. expect(documents[0]?.info.references).toEqual({
  1306. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1307. })
  1308. expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  1309. expect(documents[0]?.info.providers?.custom).toMatchObject({
  1310. settings: { apiKey: "secret" },
  1311. models: {
  1312. model: {
  1313. settings: { reasoningEffort: "high" },
  1314. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  1315. },
  1316. },
  1317. })
  1318. expect(documents[0]?.info.providers?.openai).toMatchObject({
  1319. package: Provider.aisdk("@ai-sdk/openai"),
  1320. settings: { apiKey: "secret", organization: "org" },
  1321. models: {
  1322. model: {
  1323. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1324. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  1325. },
  1326. },
  1327. })
  1328. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  1329. package: Provider.aisdk("@ai-sdk/anthropic"),
  1330. models: {
  1331. model: {
  1332. settings: {
  1333. effort: "high",
  1334. taskBudget: 4096,
  1335. metadata: { userId: "user-1" },
  1336. },
  1337. },
  1338. },
  1339. })
  1340. expect(documents[0]?.info.compaction).toEqual({
  1341. auto: true,
  1342. keep: { tokens: 2000 },
  1343. buffer: 10000,
  1344. })
  1345. expect(documents[0]?.info.mcp).toMatchObject({
  1346. timeout: { catalog: 5000, execution: 5000 },
  1347. servers: {
  1348. local: {
  1349. type: "local",
  1350. command: ["node", "server.js"],
  1351. disabled: true,
  1352. timeout: { catalog: 10000, execution: 10000 },
  1353. },
  1354. remote: {
  1355. type: "remote",
  1356. url: "https://mcp.example.com",
  1357. oauth: { client_id: "client", callback_port: 19876 },
  1358. timeout: { catalog: 20000, execution: 20000 },
  1359. },
  1360. },
  1361. })
  1362. }).pipe(Effect.provide(testLayer(tmp.path)))
  1363. }),
  1364. ),
  1365. ),
  1366. )
  1367. it.live("ignores an invalid file while loading valid config values", () =>
  1368. Effect.acquireRelease(
  1369. Effect.promise(() => tmpdir()),
  1370. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1371. ).pipe(
  1372. Effect.flatMap((tmp) =>
  1373. Effect.gen(function* () {
  1374. yield* Effect.promise(() =>
  1375. Promise.all([
  1376. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1377. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1378. ]),
  1379. )
  1380. return yield* Effect.gen(function* () {
  1381. const config = yield* Config.Service
  1382. const watcher = yield* Watcher.Test
  1383. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1384. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1385. expect(yield* watcher.subscriptions()).toContainEqual({
  1386. path: path.join(tmp.path, "opencode.jsonc"),
  1387. type: "file",
  1388. })
  1389. }).pipe(Effect.provide(testLayer(tmp.path)))
  1390. }),
  1391. ),
  1392. ),
  1393. )
  1394. it.live("loads global and ancestor configuration across the project boundary", () =>
  1395. Effect.acquireRelease(
  1396. Effect.promise(() => tmpdir()),
  1397. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1398. ).pipe(
  1399. Effect.flatMap((tmp) => {
  1400. const global = path.join(tmp.path, "global")
  1401. const root = path.join(tmp.path, "repo")
  1402. const parent = path.join(root, "packages")
  1403. const directory = path.join(parent, "app")
  1404. const globalAgents = path.join(global, "home", ".agents")
  1405. const globalClaude = path.join(global, "home", ".claude")
  1406. return Effect.gen(function* () {
  1407. yield* Effect.promise(async () => {
  1408. await fs.mkdir(global, { recursive: true })
  1409. await fs.mkdir(globalAgents, { recursive: true })
  1410. await fs.mkdir(globalClaude, { recursive: true })
  1411. await fs.mkdir(directory, { recursive: true })
  1412. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1413. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1414. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1415. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1416. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1417. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1418. await Promise.all([
  1419. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1420. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1421. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1422. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1423. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1424. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1425. fs.writeFile(
  1426. path.join(directory, ".opencode", "opencode.jsonc"),
  1427. JSON.stringify({ $schema: "directory-dot" }),
  1428. ),
  1429. ])
  1430. })
  1431. return yield* Effect.gen(function* () {
  1432. const config = yield* Config.Service
  1433. const entries = yield* config.entries()
  1434. const documents = entries.filter((entry) => entry.type === "document")
  1435. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1436. AbsolutePath.make(global),
  1437. AbsolutePath.make(path.join(root, ".opencode")),
  1438. AbsolutePath.make(path.join(directory, ".opencode")),
  1439. ])
  1440. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1441. AbsolutePath.make(globalAgents),
  1442. AbsolutePath.make(path.join(root, ".agents")),
  1443. AbsolutePath.make(path.join(directory, ".agents")),
  1444. ])
  1445. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1446. AbsolutePath.make(globalClaude),
  1447. AbsolutePath.make(path.join(root, ".claude")),
  1448. AbsolutePath.make(path.join(directory, ".claude")),
  1449. ])
  1450. expect(documents.map((document) => document.info.$schema)).toEqual([
  1451. "global",
  1452. "outside",
  1453. "root",
  1454. "parent",
  1455. "directory",
  1456. "root-dot",
  1457. "directory-dot",
  1458. ])
  1459. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1460. AbsolutePath.make(globalClaude),
  1461. AbsolutePath.make(path.join(root, ".claude")),
  1462. AbsolutePath.make(path.join(directory, ".claude")),
  1463. AbsolutePath.make(globalAgents),
  1464. AbsolutePath.make(path.join(root, ".agents")),
  1465. AbsolutePath.make(path.join(directory, ".agents")),
  1466. "global",
  1467. AbsolutePath.make(global),
  1468. "outside",
  1469. "root",
  1470. "parent",
  1471. "directory",
  1472. "root-dot",
  1473. AbsolutePath.make(path.join(root, ".opencode")),
  1474. "directory-dot",
  1475. AbsolutePath.make(path.join(directory, ".opencode")),
  1476. ])
  1477. }).pipe(
  1478. Effect.provide(
  1479. testLayer(directory, global, root, {
  1480. type: "git",
  1481. store: AbsolutePath.make(path.join(root, ".git")),
  1482. }),
  1483. ),
  1484. )
  1485. })
  1486. }),
  1487. ),
  1488. )
  1489. })