config.test.ts 40 KB

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