@openchambery/web 2.0.0-beta.3 → 2.0.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-B_JQoreg.js → AgentAvatar-O2x_1cFU.js} +1 -1
  3. package/dist/assets/AssistantView-pBywAsqy.js +3 -0
  4. package/dist/assets/{DiagramView-BNnda8qO.js → DiagramView-B984aE-R.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-VYQTSAkr.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
  6. package/dist/assets/Icon-BN87CyS4.js +1 -0
  7. package/dist/assets/MarkdownRenderer-DHiNU8n3.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-DwrHFGUp.js → MarkdownRendererImpl-xJHMGHju.js} +3 -3
  9. package/dist/assets/MarkstreamRendererImpl-BZflns6x.js +3 -0
  10. package/dist/assets/{MobileDetailNavigation-BXr6AGLT.js → MobileDetailNavigation-DUvxsvrB.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-D7OE9OP_.js → MobileShareBridge-DWo-J3Vc.js} +3 -3
  12. package/dist/assets/MobileSurface-B-Y6U5xm.js +2 -0
  13. package/dist/assets/{MultiRunWindow-DF3iIFCd.js → MultiRunWindow-rYiVSNKK.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-BjHmlwlw.js → OnboardingScreen-BLjZIBOs.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-DLtWO_Gp.js → OverlayScrollbar-CoZ4wtau.js} +1 -1
  16. package/dist/assets/SettingsView-pjD0LrHE.js +25 -0
  17. package/dist/assets/{SettingsWindow-BI5swspb.js → SettingsWindow-CFOQRwjA.js} +1 -1
  18. package/dist/assets/TerminalView-Dw5jaYkc.js +8 -0
  19. package/dist/assets/{ToolOutputDialog-BEWhBcRd.js → ToolOutputDialog-B4v-ona2.js} +1 -1
  20. package/dist/assets/appThemeRegistry-BqDCGocy.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DKuz3L8l.js → button-Ck8ipzBm.js} +1 -1
  23. package/dist/assets/collapsible-nU99prkD.js +1 -0
  24. package/dist/assets/context-menu-DMzZN1oi.js +1 -0
  25. package/dist/assets/{desktopHosts-CoDkOnyS.js → desktopHosts-DuftEj8B.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
  27. package/dist/assets/{diagram-D7T8HQgj.js → diagram-CvO4P8ds.js} +1 -1
  28. package/dist/assets/dialog-ByGUkS3c.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/es-DCjl24dV.js +15 -0
  31. package/dist/assets/fileMentionTouchSelection-Cgkxwzqb.js +2 -0
  32. package/dist/assets/{fileQueries-JrpcsOCs.js → fileQueries-DuF89nFO.js} +1 -1
  33. package/dist/assets/fr-DfJ5j0t5.js +13 -0
  34. package/dist/assets/{gitApi-CM42qUed.js → gitApi-Dwqkc2nZ.js} +3 -3
  35. package/dist/assets/{imageSource-CIl0FL10.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-CzQy8UhV.js → input-CXhFmHcq.js} +1 -1
  37. package/dist/assets/insertionBoundaries-BhWfAOPY.js +1 -0
  38. package/dist/assets/ja-B_9_w2Xf.js +15 -0
  39. package/dist/assets/ko-DlGbo3kV.js +15 -0
  40. package/dist/assets/main-CGMinhgQ.js +169 -0
  41. package/dist/assets/main-pMpcLJha.js +2 -0
  42. package/dist/assets/{markdownHeightCache-Cj1I5NDw.js → markdownHeightCache-g_UsWzbA.js} +1 -1
  43. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  44. package/dist/assets/{mcpQueries-D5mQymgM.js → mcpQueries-DfrW3VHF.js} +1 -1
  45. package/dist/assets/miniChat-YOt30ECc.js +2 -0
  46. package/dist/assets/mobile-BK1bBCYD.js +2 -0
  47. package/dist/assets/multirun-Dua24A69.js +3 -0
  48. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  49. package/dist/assets/pl-DjUsS8Ei.js +15 -0
  50. package/dist/assets/platform-X3Y3GyM5.js +6 -0
  51. package/dist/assets/projectMeta-DXdoPmbz.js +1 -0
  52. package/dist/assets/pt-BR-B8sV7qo1.js +15 -0
  53. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BOuEz_oT.js +2 -0
  55. package/dist/assets/renderMobileApp-C8Alpv-B.js +28 -0
  56. package/dist/assets/runtime-fetch-CiqQd__Y.js +1 -0
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-DX_NP2Zm.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-BSQU3e5U.js +148 -0
  60. package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
  61. package/dist/assets/sessionChangeSummary-8AIBkExf.js +2 -0
  62. package/dist/assets/{sessionLookup-myHe5ltu.js → sessionLookup-Bdwd1Fit.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-BQtmHx9r.js → sortable-tabs-strip-T-dk_L69.js} +1 -1
  64. package/dist/assets/src-OzbnJsph.css +2 -0
  65. package/dist/assets/store-Cuktref-.js +15 -0
  66. package/dist/assets/{terminalApi-BxcdiduX.js → terminalApi-C_4joKUG.js} +1 -1
  67. package/dist/assets/textarea-biAqiqnJ.js +1 -0
  68. package/dist/assets/{tooltip-Cx5gTbdY.js → tooltip-BKL7J5hx.js} +1 -1
  69. package/dist/assets/uk-Bh7HXyLp.js +15 -0
  70. package/dist/assets/{url-CDHnjlsm.js → url-DNRMq_0R.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-CNeo0aZs.js → useAppFontEffects-bafOEpgK.js} +57 -52
  72. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-CkCVOkxa.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-B4LvR8Gt.js → useEffectiveDirectory-B7htpJfU.js} +1 -1
  75. package/dist/assets/{useFontPreferences-CUnQ4TVm.js → useFontPreferences-CMrbTA0r.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-D2tnhKYV.js +415 -0
  77. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  78. package/dist/assets/useSessionAutoCleanup-BC5OOH3M.js +2 -0
  79. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  80. package/dist/assets/useUIStore-Dfx6TKvi.js +1 -0
  81. package/dist/assets/{useWorkerHighlightedLines-CJZbJDW3.js → useWorkerHighlightedLines-BNQUAMEB.js} +1 -1
  82. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  83. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  84. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  87. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  88. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  89. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  90. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  91. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  92. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  99. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-DzRqcymP.js} +2 -2
  100. package/dist/assets/zh-CN-Uzia0gk4.js +15 -0
  101. package/dist/assets/zh-TW-CiI7DLdB.js +15 -0
  102. package/dist/index.html +15 -15
  103. package/dist/mini-chat.html +15 -15
  104. package/dist/mobile.html +15 -15
  105. package/package.json +3 -2
  106. package/server/index.js +46 -27
  107. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  108. package/server/lib/browser-provider/contract.js +312 -0
  109. package/server/lib/browser-provider/host.js +421 -0
  110. package/server/lib/browser-provider/host.test.js +304 -0
  111. package/server/lib/browser-provider/routes.js +122 -0
  112. package/server/lib/browser-provider/routes.test.js +120 -0
  113. package/server/lib/browser-provider/selection-store.js +43 -0
  114. package/server/lib/browser-provider/selection-store.test.js +35 -0
  115. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  116. package/server/lib/event-stream/reasoning-projection.js +16 -1
  117. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  118. package/server/lib/git/DOCUMENTATION.md +1 -0
  119. package/server/lib/git/routes.js +7 -4
  120. package/server/lib/git/routes.test.js +34 -0
  121. package/server/lib/git/service.js +3 -1
  122. package/server/lib/llm/DOCUMENTATION.md +6 -4
  123. package/server/lib/llm/generate.js +8 -9
  124. package/server/lib/llm/generate.test.js +33 -6
  125. package/server/lib/message-queue/service.js +1 -1
  126. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  127. package/server/lib/notifications/runtime.js +128 -31
  128. package/server/lib/notifications/runtime.test.js +103 -0
  129. package/server/lib/notifications/template-runtime.js +130 -38
  130. package/server/lib/notifications/template-runtime.test.js +96 -0
  131. package/server/lib/opencode/DOCUMENTATION.md +36 -19
  132. package/server/lib/opencode/agents.js +13 -22
  133. package/server/lib/opencode/auth-state-runtime.js +3 -3
  134. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  135. package/server/lib/opencode/auth.d.ts +13 -0
  136. package/server/lib/opencode/auth.js +74 -64
  137. package/server/lib/opencode/auth.test.js +57 -0
  138. package/server/lib/opencode/commands.js +1 -1
  139. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  140. package/server/lib/opencode/config-entity-routes.js +16 -124
  141. package/server/lib/opencode/config-entity-routes.test.js +48 -11
  142. package/server/lib/opencode/config-files.d.ts +17 -0
  143. package/server/lib/opencode/config-files.js +187 -0
  144. package/server/lib/opencode/config-files.test.js +201 -0
  145. package/server/lib/opencode/core-routes.js +0 -1
  146. package/server/lib/opencode/core-routes.test.js +0 -15
  147. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  148. package/server/lib/opencode/credential-boundary.js +11 -0
  149. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  150. package/server/lib/opencode/ensure-cli.js +4 -1
  151. package/server/lib/opencode/env-config.js +4 -1
  152. package/server/lib/opencode/env-config.test.js +28 -0
  153. package/server/lib/opencode/env-runtime.test.js +18 -1
  154. package/server/lib/opencode/feature-routes-runtime.js +19 -7
  155. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  156. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  157. package/server/lib/opencode/lifecycle.js +1 -49
  158. package/server/lib/opencode/lifecycle.test.js +30 -4
  159. package/server/lib/opencode/location-release.d.ts +8 -0
  160. package/server/lib/opencode/location-release.js +32 -0
  161. package/server/lib/opencode/location-release.test.js +43 -0
  162. package/server/lib/opencode/mcp.js +1 -1
  163. package/server/lib/opencode/opencode2-pin.js +2 -1
  164. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  165. package/server/lib/opencode/plugin-routes.test.js +4 -2
  166. package/server/lib/opencode/plugins.js +12 -60
  167. package/server/lib/opencode/plugins.test.js +5 -3
  168. package/server/lib/opencode/providers.js +6 -3
  169. package/server/lib/opencode/proxy.js +13 -1
  170. package/server/lib/opencode/proxy.test.js +31 -3
  171. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  172. package/server/lib/opencode/routes.js +40 -8
  173. package/server/lib/opencode/routes.test.js +51 -10
  174. package/server/lib/opencode/runtime-contract.js +3 -2
  175. package/server/lib/opencode/runtime-contract.test.js +12 -12
  176. package/server/lib/opencode/shared.js +6 -175
  177. package/server/lib/opencode/update-discovery.js +39 -0
  178. package/server/lib/opencode/update-discovery.test.js +39 -0
  179. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  180. package/server/lib/opencode/upgrade-screen.js +292 -0
  181. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  182. package/server/lib/opencode/version-alignment.test.js +40 -0
  183. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  184. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  185. package/server/lib/question-auto-delegate/core.js +21 -13
  186. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  187. package/server/lib/question-auto-delegate/form-io.js +39 -0
  188. package/server/lib/question-auto-delegate/runtime.js +4 -24
  189. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  190. package/server/lib/quota/DOCUMENTATION.md +9 -0
  191. package/server/lib/quota/provider-credentials.test.js +29 -0
  192. package/server/lib/quota/providers/claude.js +4 -4
  193. package/server/lib/quota/providers/codex.js +4 -4
  194. package/server/lib/quota/providers/codex.test.js +1 -1
  195. package/server/lib/quota/providers/copilot.js +5 -5
  196. package/server/lib/quota/providers/google/auth.js +3 -3
  197. package/server/lib/quota/providers/google/index.js +2 -2
  198. package/server/lib/quota/providers/index.js +3 -7
  199. package/server/lib/quota/providers/index.test.js +2 -2
  200. package/server/lib/quota/providers/kimi.js +4 -4
  201. package/server/lib/quota/providers/minimax-shared.js +4 -4
  202. package/server/lib/quota/providers/nanogpt.js +4 -4
  203. package/server/lib/quota/providers/openai.js +4 -4
  204. package/server/lib/quota/providers/openrouter.js +4 -4
  205. package/server/lib/quota/providers/wafer.js +4 -4
  206. package/server/lib/quota/providers/zai.js +4 -4
  207. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  208. package/server/lib/quota/routes.js +1 -1
  209. package/server/lib/relay/DOCUMENTATION.md +3 -1
  210. package/server/lib/relay/service.js +1 -0
  211. package/server/lib/relay/service.test.js +13 -0
  212. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  213. package/server/lib/scheduled-tasks/runtime.js +12 -4
  214. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  215. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  216. package/server/lib/session-goal/runtime.js +4 -4
  217. package/server/lib/session-goal/runtime.test.js +32 -3
  218. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  219. package/server/lib/session-index/event-ingest.js +13 -0
  220. package/server/lib/session-index/event-ingest.test.js +7 -0
  221. package/server/lib/session-index/service.js +45 -0
  222. package/server/lib/session-index/service.test.js +47 -0
  223. package/server/lib/session-metadata/DOCUMENTATION.md +33 -17
  224. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  225. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  226. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  227. package/server/lib/session-metadata/routes.js +5 -1
  228. package/server/lib/session-metadata/session-archive.js +1 -1
  229. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  230. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  231. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  232. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  233. package/server/lib/session-metadata/session-projection.js +49 -2
  234. package/server/lib/session-metadata/session-projection.test.js +35 -0
  235. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  236. package/server/lib/session-title/native-runtime.test.js +59 -5
  237. package/server/lib/session-title/runtime.js +65 -19
  238. package/server/lib/session-title/session-access.js +108 -3
  239. package/server/lib/session-title/session-access.test.js +125 -7
  240. package/server/lib/tts/DOCUMENTATION.md +7 -5
  241. package/server/lib/tts/credential-source.test.js +23 -0
  242. package/server/lib/tts/routes.js +2 -2
  243. package/server/lib/tts/service.js +16 -37
  244. package/dist/assets/AssistantView-ClQAfGOT.js +0 -3
  245. package/dist/assets/Icon-BiEQEcih.js +0 -1
  246. package/dist/assets/MarkdownRenderer-CJHSsWin.js +0 -2
  247. package/dist/assets/MarkstreamRendererImpl-CSnbQMu3.js +0 -3
  248. package/dist/assets/MobileSurface-D2jUkX9l.js +0 -2
  249. package/dist/assets/SettingsView-BRxELjow.js +0 -25
  250. package/dist/assets/TerminalView-BU3x8-fr.js +0 -8
  251. package/dist/assets/appThemeRegistry-4MR0F7Jz.js +0 -1
  252. package/dist/assets/collapsible-DXbDynN4.js +0 -3
  253. package/dist/assets/context-menu-CqolsLCm.js +0 -1
  254. package/dist/assets/desktopRelayRestore-DDlVKyOE.js +0 -1
  255. package/dist/assets/dialog-DQYl3W_o.js +0 -1
  256. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  257. package/dist/assets/es-CN3E_zXj.js +0 -15
  258. package/dist/assets/fileMentionTouchSelection-BZ6ovBYH.js +0 -4
  259. package/dist/assets/fr-9zZvQZD2.js +0 -13
  260. package/dist/assets/insertionBoundaries-CtkZ9720.js +0 -1
  261. package/dist/assets/ja-Dnjk_82N.js +0 -15
  262. package/dist/assets/ko-BxCEE99C.js +0 -15
  263. package/dist/assets/main-BILgBI3B.js +0 -169
  264. package/dist/assets/main-BeveiW6F.js +0 -2
  265. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  266. package/dist/assets/miniChat-BgrzM3u7.js +0 -2
  267. package/dist/assets/mobile-DtYoQE8C.js +0 -2
  268. package/dist/assets/multirun-jy8dEI96.js +0 -4
  269. package/dist/assets/persistence-C9LQpiOS.js +0 -3
  270. package/dist/assets/pl-CNNsWnPM.js +0 -15
  271. package/dist/assets/platform-BQghHRfp.js +0 -6
  272. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  273. package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +0 -2
  274. package/dist/assets/renderMobileApp-u3c4GtFC.js +0 -28
  275. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  276. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  277. package/dist/assets/runtimeConfig-BIGBRGQI.js +0 -2
  278. package/dist/assets/runtimeEndpointReset-D2savkuw.js +0 -147
  279. package/dist/assets/sessionChangeSummary-eIbpSfrO.js +0 -2
  280. package/dist/assets/src-CLN7Z-zx.css +0 -2
  281. package/dist/assets/store-DLCiZaA6.js +0 -15
  282. package/dist/assets/textarea-CakKvqFw.js +0 -1
  283. package/dist/assets/uk-rwQrxeYM.js +0 -15
  284. package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +0 -1
  285. package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +0 -414
  286. package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +0 -2
  287. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  288. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  289. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  290. package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
  291. package/server/lib/opencode/snippets.js +0 -242
  292. package/server/lib/opencode/snippets.test.js +0 -97
@@ -0,0 +1,72 @@
1
+ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { readConfigFile } from './config-files.js';
6
+
7
+ let root;
8
+ let directory;
9
+ let mcp;
10
+ let agents;
11
+ let providers;
12
+ let plugins;
13
+ beforeEach(async () => {
14
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-entity-layers-'));
15
+ directory = path.join(root, 'xdg', 'opencode');
16
+ fs.mkdirSync(directory, { recursive: true });
17
+ vi.stubEnv('XDG_CONFIG_HOME', path.dirname(directory));
18
+ vi.stubEnv('OPENCODE_CONFIG', '');
19
+ vi.stubEnv('OPENCODE_CONFIG_DIR', '');
20
+ vi.spyOn(os, 'homedir').mockReturnValue(root);
21
+ vi.resetModules();
22
+ [mcp, agents, providers, plugins] = await Promise.all([import('./mcp.js'), import('./agents.js'), import('./providers.js'), import('./plugins.js')]);
23
+ });
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ vi.unstubAllEnvs();
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ });
29
+
30
+ describe('Web/Electron entity physical provenance', () => {
31
+ it('MCP CRUD edits only the source document and preserves comments', () => {
32
+ const json = path.join(directory, 'opencode.json');
33
+ const jsonc = path.join(directory, 'opencode.jsonc');
34
+ fs.writeFileSync(json, '{"mcp":{"lower":{"type":"local","command":["lower"]}}}');
35
+ fs.writeFileSync(jsonc, '{ // retained\n"mcp":{"higher":{"type":"local","command":["higher"]}}}');
36
+ expect(mcp.listMcpConfigs().map((entry) => entry.name).sort()).toEqual(['higher', 'lower']);
37
+ mcp.updateMcpConfig('lower', { enabled: false });
38
+ expect(readConfigFile(json)).toMatchObject({ mcp: { lower: { enabled: false } } });
39
+ mcp.createMcpConfig('new', { type: 'local', command: ['new'] });
40
+ expect(readConfigFile(jsonc).mcp.lower).toBeUndefined();
41
+ mcp.deleteMcpConfig('higher');
42
+ expect(readConfigFile(jsonc).mcp.higher).toBeUndefined();
43
+ expect(fs.readFileSync(jsonc, 'utf8')).toContain('// retained');
44
+ });
45
+
46
+ it('agent and provider metadata point to physical layers, including scoped deletion', () => {
47
+ const json = path.join(directory, 'opencode.json');
48
+ const jsonc = path.join(directory, 'opencode.jsonc');
49
+ fs.writeFileSync(json, '{"agent":{"lower":{"description":"lower"}},"provider":{"lower":{}}}');
50
+ fs.writeFileSync(jsonc, '{ // retained\n"agent":{"higher":{"description":"higher"}},"provider":{"higher":{}}}');
51
+ expect(agents.getAgentSources('lower').json.path).toBe(json);
52
+ expect(agents.getAgentSources('higher').json.path).toBe(jsonc);
53
+ agents.deleteAgent('lower', undefined, 'user');
54
+ expect(readConfigFile(json).agent).toBeUndefined();
55
+ expect(readConfigFile(jsonc).agent.higher).toBeDefined();
56
+ expect(providers.getProviderSources('lower').sources.user.path).toBe(json);
57
+ providers.removeProviderConfig('lower');
58
+ expect(readConfigFile(json).provider).toBeUndefined();
59
+ expect(readConfigFile(jsonc).provider.higher).toBeDefined();
60
+ });
61
+
62
+ it('plugin edits locate the source containing the requested spec', () => {
63
+ const json = path.join(directory, 'opencode.json');
64
+ const jsonc = path.join(directory, 'opencode.jsonc');
65
+ fs.writeFileSync(json, '{"plugin":["lower-plugin"]}');
66
+ fs.writeFileSync(jsonc, '{ // retained\n"plugin":["higher-plugin"]}');
67
+ const entry = plugins.listPluginEntries().find((item) => item.spec === 'lower-plugin');
68
+ plugins.updatePluginEntry(entry.id, { spec: 'updated-plugin' });
69
+ expect(readConfigFile(json).plugin).toEqual(['updated-plugin']);
70
+ expect(readConfigFile(jsonc).plugin).toEqual(['higher-plugin']);
71
+ });
72
+ });
@@ -1,9 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import { parse as parseJsonc } from 'jsonc-parser';
5
4
  import { OpenCode } from '@opencode/client';
6
5
  import { projectProviderCatalog } from './provider-catalog.js';
6
+ import { getGlobalConfigDirectory, writeConfigText } from './config-files.js';
7
7
 
8
8
  // Compose official v2 provider.list + model.list (+ optional default) into the
9
9
  // catalog shape consumed by projectProviderCatalog. Missing arrays are failure.
@@ -60,7 +60,7 @@ const createOpenCodeClient = ({ baseUrl, headers, fetch: fetchImpl }) =>
60
60
 
61
61
  const MAX_GLOBAL_CONFIG_SIZE = 2 * 1024 * 1024;
62
62
  const GLOBAL_CONFIG_FILES = {
63
- opencode: ['opencode.json', 'opencode.jsonc'],
63
+ opencode: ['opencode.jsonc', 'opencode.json'],
64
64
  'oh-my-opencode-slim': ['oh-my-opencode-slim.json', 'oh-my-opencode-slim.jsonc'],
65
65
  'oh-my-openagent': ['oh-my-openagent.json', 'oh-my-openagent.jsonc'],
66
66
  };
@@ -84,6 +84,10 @@ function resolveGlobalConfigPath(target, configDirectory) {
84
84
  if (!fileNames) {
85
85
  return null;
86
86
  }
87
+ if (target === 'opencode' && process.env.OPENCODE_CONFIG) {
88
+ const filePath = path.resolve(process.env.OPENCODE_CONFIG);
89
+ return { target, fileNames: [path.basename(filePath)], fileName: path.basename(filePath), filePath };
90
+ }
87
91
  return {
88
92
  target,
89
93
  fileNames,
@@ -99,7 +103,7 @@ async function findGlobalConfigPath(target, configDirectory) {
99
103
  }
100
104
 
101
105
  for (const fileName of configPath.fileNames) {
102
- const filePath = path.join(configDirectory, fileName);
106
+ const filePath = path.join(path.dirname(configPath.filePath), fileName);
103
107
  try {
104
108
  if ((await fs.stat(filePath)).isFile()) {
105
109
  return { ...configPath, fileName, filePath };
@@ -153,13 +157,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
153
157
  createMcpConfig,
154
158
  updateMcpConfig,
155
159
  deleteMcpConfig,
156
- listSnippets,
157
- getSnippet,
158
- createSnippet,
159
- updateSnippet,
160
- deleteSnippet,
161
- expandSnippets,
162
- configDirectory = path.join(os.homedir(), '.config', 'opencode'),
160
+ configDirectory = getGlobalConfigDirectory(),
163
161
  } = dependencies;
164
162
 
165
163
  app.get('/api/config/catalog/providers', async (req, res) => {
@@ -186,7 +184,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
186
184
  const [providersResult, modelsResult, defaultResult] = await Promise.all([
187
185
  client.provider.list({ location }),
188
186
  client.model.list({ location }),
189
- client.model.default({ location }),
187
+ client.model.default({ location }).catch(() => undefined),
190
188
  ]);
191
189
  const source = composeV2ProviderCatalogSource(
192
190
  providersResult?.data,
@@ -254,10 +252,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
254
252
  }
255
253
 
256
254
  try {
257
- await fs.mkdir(configDirectory, { recursive: true });
258
- const temporaryPath = `${target.filePath}.${process.pid}.${Date.now()}.tmp`;
259
- await fs.writeFile(temporaryPath, content, 'utf8');
260
- await fs.rename(temporaryPath, target.filePath);
255
+ writeConfigText(content, target.filePath);
261
256
  return res.json({
262
257
  target: req.params.target,
263
258
  fileName: target.fileName,
@@ -531,7 +526,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
531
526
  const client = createOpenCodeClient({
532
527
  baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
533
528
  headers: getOpenCodeAuthHeaders(),
534
- fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
529
+ fetch: (request, init) => fetch(request, {
530
+ ...init,
531
+ signal: init?.signal
532
+ ? AbortSignal.any([init.signal, AbortSignal.timeout(8_000)])
533
+ : AbortSignal.timeout(8_000),
534
+ }),
535
535
  });
536
536
  const response = await client.command.list({ location: { directory } });
537
537
  if (!Array.isArray(response?.data)) {
@@ -674,112 +674,4 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
674
674
  }
675
675
  });
676
676
 
677
- app.get('/api/config/snippets', async (req, res) => {
678
- try {
679
- const { directory, error } = await resolveOptionalProjectDirectory(req);
680
- if (error) {
681
- return res.status(400).json({ error });
682
- }
683
- res.json(listSnippets(directory));
684
- } catch (error) {
685
- console.error('[API:GET /api/config/snippets] Failed:', error);
686
- res.status(500).json({ error: error.message || 'Failed to list snippets' });
687
- }
688
- });
689
-
690
- app.post('/api/config/snippets/expand', async (req, res) => {
691
- try {
692
- const { directory, error } = await resolveOptionalProjectDirectory(req);
693
- if (error) {
694
- return res.status(400).json({ error });
695
- }
696
- res.json({ text: expandSnippets(req.body?.text ?? '', directory) });
697
- } catch (error) {
698
- console.error('[API:POST /api/config/snippets/expand] Failed:', error);
699
- res.status(500).json({ error: error.message || 'Failed to expand snippets' });
700
- }
701
- });
702
-
703
- app.get('/api/config/snippets/:name', async (req, res) => {
704
- try {
705
- const name = req.params.name;
706
- const { directory, error } = await resolveOptionalProjectDirectory(req);
707
- if (error) {
708
- return res.status(400).json({ error });
709
- }
710
- const snippet = getSnippet(name, directory);
711
- if (!snippet) {
712
- return res.status(404).json({ error: `Snippet "${name}" not found` });
713
- }
714
- res.json(snippet);
715
- } catch (error) {
716
- console.error('[API:GET /api/config/snippets/:name] Failed:', error);
717
- if (error.message?.includes('Snippet name')) {
718
- return res.status(400).json({ error: error.message });
719
- }
720
- res.status(500).json({ error: error.message || 'Failed to get snippet' });
721
- }
722
- });
723
-
724
- app.post('/api/config/snippets/:name', async (req, res) => {
725
- try {
726
- const name = req.params.name;
727
- const { directory, error } = await resolveOptionalProjectDirectory(req);
728
- if (error) {
729
- return res.status(400).json({ error });
730
- }
731
- const snippet = createSnippet(name, req.body || {}, directory, req.body?.scope || 'global');
732
- res.json({ success: true, snippet });
733
- } catch (error) {
734
- console.error('[API:POST /api/config/snippets/:name] Failed:', error);
735
- if (error.message?.includes('already exists')) {
736
- return res.status(409).json({ error: error.message });
737
- }
738
- if (error.message?.includes('Snippet name') || error.message?.includes('Project directory')) {
739
- return res.status(400).json({ error: error.message });
740
- }
741
- res.status(500).json({ error: error.message || 'Failed to create snippet' });
742
- }
743
- });
744
-
745
- app.patch('/api/config/snippets/:name', async (req, res) => {
746
- try {
747
- const name = req.params.name;
748
- const { directory, error } = await resolveOptionalProjectDirectory(req);
749
- if (error) {
750
- return res.status(400).json({ error });
751
- }
752
- res.json({ success: true, snippet: updateSnippet(name, req.body || {}, directory) });
753
- } catch (error) {
754
- console.error('[API:PATCH /api/config/snippets/:name] Failed:', error);
755
- if (error.message?.includes('not found')) {
756
- return res.status(404).json({ error: error.message });
757
- }
758
- if (error.message?.includes('Snippet name')) {
759
- return res.status(400).json({ error: error.message });
760
- }
761
- res.status(500).json({ error: error.message || 'Failed to update snippet' });
762
- }
763
- });
764
-
765
- app.delete('/api/config/snippets/:name', async (req, res) => {
766
- try {
767
- const name = req.params.name;
768
- const { directory, error } = await resolveOptionalProjectDirectory(req);
769
- if (error) {
770
- return res.status(400).json({ error });
771
- }
772
- deleteSnippet(name, directory);
773
- res.json({ success: true });
774
- } catch (error) {
775
- console.error('[API:DELETE /api/config/snippets/:name] Failed:', error);
776
- if (error.message?.includes('not found')) {
777
- return res.status(404).json({ error: error.message });
778
- }
779
- if (error.message?.includes('Snippet name')) {
780
- return res.status(400).json({ error: error.message });
781
- }
782
- res.status(500).json({ error: error.message || 'Failed to delete snippet' });
783
- }
784
- });
785
677
  };
@@ -38,12 +38,6 @@ const createDependencies = (getCommandSources, configDirectory, getAgentSources
38
38
  createMcpConfig: vi.fn(),
39
39
  updateMcpConfig: vi.fn(),
40
40
  deleteMcpConfig: vi.fn(),
41
- listSnippets: vi.fn(),
42
- getSnippet: vi.fn(),
43
- createSnippet: vi.fn(),
44
- updateSnippet: vi.fn(),
45
- deleteSnippet: vi.fn(),
46
- expandSnippets: vi.fn(),
47
41
  configDirectory,
48
42
  buildOpenCodeUrl: () => 'http://opencode-upstream:4096/',
49
43
  getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic example' }),
@@ -69,6 +63,31 @@ describe('V2 configuration persistence', () => {
69
63
  });
70
64
 
71
65
  describe('config entity command metadata route', () => {
66
+ it('preserves real SDK authentication and request options when fetching the catalog', async () => {
67
+ const { OpenCode: realOpenCode } = await vi.importActual('@opencode/client');
68
+ createOpencodeClient.mockImplementation(realOpenCode.make);
69
+ const upstream = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
70
+ if (new Headers(init?.headers).get('Authorization') !== 'Basic example') {
71
+ return new Response('Unauthorized', { status: 401 });
72
+ }
73
+ return Response.json({ data: [{ name: 'review', description: 'Review changes' }] });
74
+ });
75
+ try {
76
+ const app = express();
77
+ app.use(express.json());
78
+ registerConfigEntityRoutes(app, createDependencies(() => ({ md: { exists: false }, json: { exists: false } })));
79
+ const response = await request(app).post('/api/config/commands/metadata').send({ catalog: true });
80
+ expect(response.status).toBe(200);
81
+ expect(response.body.commands).toEqual([expect.objectContaining({ name: 'review' })]);
82
+ const [url, init] = upstream.mock.calls[0];
83
+ expect(new URL(url).pathname).toBe('/api/command');
84
+ expect(init.method).toBe('GET');
85
+ expect(init.signal).toBeInstanceOf(AbortSignal);
86
+ } finally {
87
+ upstream.mockRestore();
88
+ }
89
+ });
90
+
72
91
  it('returns metadata for many commands through one request', async () => {
73
92
  const app = express();
74
93
  app.use(express.json());
@@ -186,6 +205,22 @@ describe('config entity command metadata route', () => {
186
205
  });
187
206
 
188
207
  describe('provider catalog route', () => {
208
+ it('keeps the model catalog available when the optional default request fails', async () => {
209
+ const app = express();
210
+ createOpencodeClient.mockReturnValue({
211
+ provider: { list: vi.fn(async () => ({ data: [{ id: 'openai', name: 'OpenAI' }] })) },
212
+ model: {
213
+ list: vi.fn(async () => ({ data: [{ id: 'model', providerID: 'openai', name: 'Model' }] })),
214
+ default: vi.fn(async () => { throw new Error('default unavailable'); }),
215
+ },
216
+ });
217
+ registerConfigEntityRoutes(app, createDependencies(vi.fn()));
218
+ const response = await request(app).get('/api/config/catalog/providers').expect(200);
219
+ expect(response.body.providers[0].models.model).toEqual({ id: 'model', name: 'Model' });
220
+ expect(response.body.default).toEqual({});
221
+ expect(response.body.partial).toBe(false);
222
+ });
223
+
189
224
  it('uses the resolved directory, OpenCode auth, SDK providers call, and explicit catalog route', async () => {
190
225
  const app = express();
191
226
  const resolveProjectDirectory = vi.fn(async () => ({ directory: '/project/from-header' }));
@@ -418,6 +453,8 @@ describe('config entity agent metadata route', () => {
418
453
  });
419
454
 
420
455
  describe('global raw configuration routes', () => {
456
+ beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
457
+ afterEach(() => vi.unstubAllEnvs());
421
458
  it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
422
459
  const app = express();
423
460
  app.use(express.json());
@@ -462,7 +499,7 @@ describe('global raw configuration routes', () => {
462
499
  await fs.rm(configDirectory, { recursive: true, force: true });
463
500
  });
464
501
 
465
- it('discovers only existing configuration targets and prefers JSON files', async () => {
502
+ it('discovers only existing targets and edits the winning global JSONC document', async () => {
466
503
  const app = express();
467
504
  app.use(express.json());
468
505
  const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
@@ -475,20 +512,20 @@ describe('global raw configuration routes', () => {
475
512
  const available = await request(app).get('/api/config/global').expect(200);
476
513
  expect(available.body).toEqual({
477
514
  targets: [
478
- { target: 'opencode', fileName: 'opencode.json' },
515
+ { target: 'opencode', fileName: 'opencode.jsonc' },
479
516
  { target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
480
517
  ],
481
518
  });
482
519
 
483
520
  const openCode = await request(app).get('/api/config/global/opencode').expect(200);
484
- expect(openCode.body).toMatchObject({ fileName: 'opencode.json', content: '{ "model": "json-model" }' });
521
+ expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
485
522
 
486
523
  await request(app)
487
524
  .put('/api/config/global/opencode')
488
525
  .send({ content: '{ "model": "updated-json-model" }' })
489
526
  .expect(200);
490
- expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('updated-json-model');
491
- expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('jsonc-model');
527
+ expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('json-model');
528
+ expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('updated-json-model');
492
529
 
493
530
  await fs.rm(configDirectory, { recursive: true, force: true });
494
531
  });
@@ -0,0 +1,17 @@
1
+ export type ConfigObject = Record<string, unknown>;
2
+ export type ConfigDocument = { path: string; scope: 'user' | 'project' | 'custom'; config: ConfigObject };
3
+ export type ConfigLayers = {
4
+ userConfig: ConfigObject; projectConfig: ConfigObject; customConfig: ConfigObject; mergedConfig: ConfigObject;
5
+ userLayers: ConfigDocument[]; projectLayers: ConfigDocument[]; documents: ConfigDocument[];
6
+ paths: { userPath: string; projectPath: string | null; customPath: string | null };
7
+ };
8
+ export function getGlobalConfigDirectory(): string;
9
+ export function isPlainObject(value: unknown): value is ConfigObject;
10
+ export function readConfigFile(filePath?: string | null): ConfigObject;
11
+ export function readConfigLayers(workingDirectory?: string): ConfigLayers;
12
+ export function readConfig(workingDirectory?: string): ConfigObject;
13
+ export function getConfigForPath(layers: ConfigLayers, targetPath?: string | null): ConfigObject;
14
+ export function getJsonEntrySource(layers: ConfigLayers, sectionKey: string, entryName: string): { section: unknown; config: ConfigObject | null; path: string | null; exists: boolean };
15
+ export function getJsonWriteTarget(layers: ConfigLayers, preferredScope: string): { config: ConfigObject; path: string };
16
+ export function writeConfig(config: ConfigObject, filePath?: string): void;
17
+ export function writeConfigText(content: string, filePath: string): void;
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { parse, parseTree, findNodeAtLocation, createScanner, SyntaxKind, modify, applyEdits } from 'jsonc-parser';
7
+
8
+ export const getGlobalConfigDirectory = () => process.env.OPENCODE_CONFIG_DIR
9
+ ? path.resolve(process.env.OPENCODE_CONFIG_DIR)
10
+ : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode');
11
+ export const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
12
+
13
+ function parseConfigObject(content, filePath) {
14
+ const errors = [];
15
+ const value = parse(content.replace(/^\uFEFF/, ''), errors, { allowTrailingComma: true, allowEmptyContent: true });
16
+ if (errors.length || (value !== undefined && !isPlainObject(value))) {
17
+ throw Object.assign(new Error(`Invalid JSONC configuration: ${filePath}`), { code: 'INVALID_JSONC' });
18
+ }
19
+ return value ?? {};
20
+ }
21
+
22
+ function readText(filePath) {
23
+ try {
24
+ return fs.readFileSync(filePath, 'utf8');
25
+ } catch (error) {
26
+ if (error.code === 'ENOENT') return null;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ export function readConfigFile(filePath) {
32
+ if (!filePath) return {};
33
+ const text = readText(filePath);
34
+ return text === null ? {} : parseConfigObject(text, filePath);
35
+ }
36
+
37
+ function mergeConfigs(base, override) {
38
+ const result = { ...base };
39
+ for (const [key, value] of Object.entries(override)) {
40
+ Object.defineProperty(result, key, {
41
+ value: isPlainObject(result[key]) && isPlainObject(value) ? mergeConfigs(result[key], value) : value,
42
+ enumerable: true, configurable: true, writable: true,
43
+ });
44
+ }
45
+ return result;
46
+ }
47
+
48
+ const names = ['opencode.json', 'opencode.jsonc'];
49
+
50
+ export function readConfigLayers(workingDirectory) {
51
+ const directory = getGlobalConfigDirectory();
52
+ const userPaths = names.map((name) => path.join(directory, name));
53
+ const customPath = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
54
+ const ancestors = [];
55
+ if (workingDirectory && path.resolve(workingDirectory) !== path.resolve(directory)) {
56
+ let current = path.resolve(workingDirectory);
57
+ while (true) {
58
+ ancestors.unshift(current);
59
+ const parent = path.dirname(current);
60
+ if (parent === current) break;
61
+ current = parent;
62
+ }
63
+ }
64
+ const projectPaths = [
65
+ ...ancestors.flatMap((dir) => names.map((name) => path.join(dir, name))),
66
+ ...ancestors.flatMap((dir) => names.map((name) => path.join(dir, '.opencode', name))),
67
+ ].filter((filePath) => !userPaths.includes(filePath));
68
+ const load = (paths, scope) => paths.flatMap((filePath) => {
69
+ const text = readText(filePath);
70
+ return text === null ? [] : [{ path: filePath, scope, config: parseConfigObject(text, filePath) }];
71
+ });
72
+ const userLayers = load(userPaths, 'user');
73
+ const projectLayers = load(projectPaths, 'project');
74
+ const customConfig = readConfigFile(customPath);
75
+ const documents = [...userLayers, ...(customPath ? [{ path: customPath, scope: 'custom', config: customConfig }] : []), ...projectLayers];
76
+ const merge = (layers) => layers.reduce((config, layer) => mergeConfigs(config, layer.config), {});
77
+ return {
78
+ userConfig: merge(userLayers), projectConfig: merge(projectLayers), customConfig,
79
+ mergedConfig: merge(documents), userLayers, projectLayers, documents,
80
+ paths: {
81
+ userPath: userLayers.at(-1)?.path ?? path.join(directory, 'opencode.jsonc'),
82
+ projectPath: projectLayers.at(-1)?.path ?? (workingDirectory ? path.join(workingDirectory, '.opencode', 'opencode.jsonc') : null),
83
+ customPath,
84
+ },
85
+ };
86
+ }
87
+
88
+ export const readConfig = (workingDirectory) => readConfigLayers(workingDirectory).mergedConfig;
89
+
90
+ export function getConfigForPath(layers, targetPath) {
91
+ const filePath = targetPath || layers.paths.userPath;
92
+ return layers.documents.findLast((layer) => layer.path === filePath)?.config ?? readConfigFile(filePath);
93
+ }
94
+
95
+ export function getJsonEntrySource(layers, sectionKey, entryName) {
96
+ for (const layer of layers.documents.toReversed()) {
97
+ const section = layer.config?.[sectionKey]?.[entryName];
98
+ if (section !== undefined) return { section, config: layer.config, path: layer.path, exists: true };
99
+ }
100
+ return { section: null, config: null, path: null, exists: false };
101
+ }
102
+
103
+ export function getJsonWriteTarget(layers, preferredScope) {
104
+ const filePath = preferredScope === 'project' && layers.paths.projectPath
105
+ ? layers.paths.projectPath : layers.paths.customPath || layers.paths.userPath;
106
+ return { config: getConfigForPath(layers, filePath), path: filePath };
107
+ }
108
+
109
+ function removalEdits(text, propertyPath) {
110
+ const node = findNodeAtLocation(parseTree(text), propertyPath)?.parent;
111
+ const parent = node?.parent;
112
+ if (!node || parent?.type !== 'object') throw new Error('Cannot locate configuration property');
113
+ const siblings = parent.children;
114
+ const index = siblings.indexOf(node);
115
+ const end = node.offset + node.length;
116
+ const scanner = createScanner(text, true);
117
+ const comma = (start, stop) => {
118
+ scanner.setPosition(start);
119
+ return scanner.scan() === SyntaxKind.CommaToken && scanner.getTokenOffset() < stop ? scanner.getTokenOffset() : -1;
120
+ };
121
+ let separator = comma(end, siblings[index + 1]?.offset ?? parent.offset + parent.length - 1);
122
+ if (separator < 0) {
123
+ const previous = siblings[index - 1];
124
+ separator = comma(previous ? previous.offset + previous.length : parent.offset + 1, node.offset);
125
+ }
126
+ return [{ offset: node.offset, length: node.length, content: '' }, ...(separator < 0 ? [] : [{ offset: separator, length: 1, content: '' }])];
127
+ }
128
+
129
+ function editConfig(existing, desired, filePath) {
130
+ const current = parseConfigObject(existing, filePath);
131
+ const bom = existing.startsWith('\uFEFF') ? '\uFEFF' : '';
132
+ let text = existing.slice(bom.length);
133
+ if (!parseTree(text)) return `${existing}${existing && !existing.endsWith('\n') ? '\n' : ''}${JSON.stringify(desired, null, 2)}\n`;
134
+ const indentation = text.match(/\n([\t ]+)"/)?.[1] ?? ' ';
135
+ const formattingOptions = { insertSpaces: !indentation.includes('\t'), tabSize: indentation.length, eol: text.includes('\r\n') ? '\r\n' : '\n' };
136
+ const visit = (before, after, keys) => {
137
+ if (isDeepStrictEqual(before, after)) return;
138
+ if (isPlainObject(before) && isPlainObject(after)) {
139
+ for (const key of Object.keys(before)) {
140
+ if (!Object.hasOwn(after, key)) text = applyEdits(text, removalEdits(text, [...keys, key]));
141
+ }
142
+ for (const key of Object.keys(after)) visit(before[key], after[key], [...keys, key]);
143
+ } else {
144
+ text = applyEdits(text, modify(text, keys, after, { formattingOptions }));
145
+ }
146
+ };
147
+ visit(current, desired, []);
148
+ if (!isDeepStrictEqual(parseConfigObject(text, filePath), desired)) throw new Error('Configuration edit did not round-trip');
149
+ return bom + text;
150
+ }
151
+
152
+ // Shared by structured entity edits and the raw editor; failure never truncates the original.
153
+ export function writeConfigText(content, filePath) {
154
+ parseConfigObject(content, filePath);
155
+ let exists = false;
156
+ try {
157
+ fs.lstatSync(filePath);
158
+ exists = true;
159
+ } catch (error) {
160
+ if (error.code !== 'ENOENT') throw error;
161
+ }
162
+ // Resolve before staging: rename must replace the dotfile target, not its link.
163
+ // lstat distinguishes new files from dangling links, whose realpath must fail.
164
+ const targetPath = exists ? fs.realpathSync(filePath) : filePath;
165
+ const original = readText(targetPath);
166
+ if (original !== null) parseConfigObject(original, filePath);
167
+ if (content === original) return;
168
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
169
+ const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
170
+ try {
171
+ const mode = original === null ? 0o600 : fs.statSync(targetPath).mode & 0o777;
172
+ fs.writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode });
173
+ if (original !== null) {
174
+ fs.chmodSync(temporaryPath, mode);
175
+ fs.copyFileSync(targetPath, `${filePath}.openchamber.backup`);
176
+ }
177
+ fs.renameSync(temporaryPath, targetPath);
178
+ } finally {
179
+ fs.rmSync(temporaryPath, { force: true });
180
+ }
181
+ }
182
+
183
+ export function writeConfig(config, filePath = path.join(getGlobalConfigDirectory(), 'opencode.jsonc')) {
184
+ const desired = JSON.parse(JSON.stringify(config));
185
+ if (!isPlainObject(desired)) throw new Error('Configuration must be an object');
186
+ writeConfigText(editConfig(readText(filePath) ?? '', desired, filePath), filePath);
187
+ }