config.test.ts 58 KB

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