@openchambery/web 2.0.0-beta.8 → 2.0.0

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 (237) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-C1MGZ7qC.js} +1 -1
  3. package/dist/assets/AssistantView-vqCFvVo0.js +3 -0
  4. package/dist/assets/{DiagramView-BBPsLqLq.js → DiagramView-CYO5iVVm.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-BMSk_tY-.js} +1 -1
  6. package/dist/assets/Icon-BdzPRCSy.js +1 -0
  7. package/dist/assets/MarkdownRenderer-FMynjkGR.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-CD0RYNWX.js → MarkdownRendererImpl-DoWvw5jj.js} +3 -3
  9. package/dist/assets/{MarkstreamRendererImpl-CECVYTH6.js → MarkstreamRendererImpl-DoeYajmw.js} +1 -1
  10. package/dist/assets/{MobileDetailNavigation-DOwW-F0i.js → MobileDetailNavigation-BzBz-co_.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-d0hQ2FSQ.js → MobileShareBridge-D1Uoj6l_.js} +3 -3
  12. package/dist/assets/MobileSurface-8YpZ51Qo.js +2 -0
  13. package/dist/assets/{MultiRunWindow-DOdFEgMg.js → MultiRunWindow-BxcYUPta.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-yRdQTR1M.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-S-7EePsU.js → OverlayScrollbar-BUjtqnxn.js} +1 -1
  16. package/dist/assets/SettingsView-D7KrvqvH.js +25 -0
  17. package/dist/assets/{SettingsWindow-vKn-MWGU.js → SettingsWindow-DX2w-cDc.js} +1 -1
  18. package/dist/assets/{TerminalView-BgPjY0CO.js → TerminalView-CiUcCQPm.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-DtT3wbie.js → ToolOutputDialog-DQvXtK71.js} +1 -1
  20. package/dist/assets/appThemeRegistry-BXC6cIDE.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DIIrsMjW.js → button-CUwjpv-i.js} +1 -1
  23. package/dist/assets/{collapsible-CnvUeYst.js → collapsible-paItOH3A.js} +1 -1
  24. package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-K6zNumqN.js} +1 -1
  25. package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-DuftEj8B.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
  27. package/dist/assets/{diagram-rvn7_qBM.js → diagram-D6nY5hbD.js} +1 -1
  28. package/dist/assets/dialog-PjKWayIU.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
  31. package/dist/assets/fileMentionTouchSelection-78v5mNTE.js +2 -0
  32. package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-DuF89nFO.js} +1 -1
  33. package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
  34. package/dist/assets/{gitApi-D9ygh31C.js → gitApi-eoKSJcvW.js} +4 -4
  35. package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-BVu-iykl.js → input-BeVw4D0W.js} +1 -1
  37. package/dist/assets/insertionBoundaries-BI3RHOpf.js +1 -0
  38. package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
  39. package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
  40. package/dist/assets/main-CQpUPjPK.js +169 -0
  41. package/dist/assets/{main-B97oNWoh.js → main-MN0YKJ0_.js} +2 -2
  42. package/dist/assets/main-qSeeJRW1.css +1 -0
  43. package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-CGCWmHgq.js} +1 -1
  44. package/dist/assets/{mcpQueries-zC4-V5b8.js → mcpQueries-X424Iq2z.js} +1 -1
  45. package/dist/assets/miniChat-DFIl76ku.js +2 -0
  46. package/dist/assets/mobile-C3fWXGQJ.js +2 -0
  47. package/dist/assets/multirun-DuSX3YRs.js +3 -0
  48. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  49. package/dist/assets/pl-B714sdql.js +15 -0
  50. package/dist/assets/platform-jCZ3KfMQ.js +6 -0
  51. package/dist/assets/projectMeta-Cn08pTNi.js +1 -0
  52. package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
  53. package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-CRPdctIn.js +2 -0
  55. package/dist/assets/renderMobileApp-CVURQbeh.js +28 -0
  56. package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-xivE1wxC.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-B8DzKzwg.js +148 -0
  60. package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
  61. package/dist/assets/sessionChangeSummary-yBsR2pyd.js +2 -0
  62. package/dist/assets/{sessionLookup-DcyGP3xg.js → sessionLookup-1Z1FWZRB.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-BsObV7Pl.js → sortable-tabs-strip-D611LPyd.js} +1 -1
  64. package/dist/assets/src-TBM7scxy.css +2 -0
  65. package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
  66. package/dist/assets/{terminalApi-Dhqqp4xi.js → terminalApi-BZW_rAc3.js} +1 -1
  67. package/dist/assets/{textarea-BoXpPjTd.js → textarea-QtZjf4L8.js} +1 -1
  68. package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-DDNnmcXI.js} +1 -1
  69. package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
  70. package/dist/assets/{url-CGbJbiJq.js → url-DNRMq_0R.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-BauzQXF0.js → useAppFontEffects-DJy8P5By.js} +51 -51
  72. package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-DlwSomVK.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-qDoROBC_.js → useEffectiveDirectory-eJorVTT9.js} +1 -1
  75. package/dist/assets/{useFontPreferences-Xw1vSkmC.js → useFontPreferences-Bjgt_lzl.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-A_ZNTfpl.js +415 -0
  77. package/dist/assets/useSessionAutoCleanup-CqC3dEou.js +2 -0
  78. package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-Dfx6TKvi.js} +1 -1
  79. package/dist/assets/{useWorkerHighlightedLines-D8luxgem.js → useWorkerHighlightedLines-B0vSL6jr.js} +1 -1
  80. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  81. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  82. package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
  83. package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
  84. package/dist/index.html +13 -13
  85. package/dist/mini-chat.html +13 -13
  86. package/dist/mobile.html +13 -13
  87. package/package.json +3 -3
  88. package/server/index.js +8 -20
  89. package/server/lib/client-auth/remote-clients.js +15 -0
  90. package/server/lib/client-auth/remote-clients.test.js +42 -1
  91. package/server/lib/git/DOCUMENTATION.md +1 -0
  92. package/server/lib/git/routes.js +7 -4
  93. package/server/lib/git/routes.test.js +34 -0
  94. package/server/lib/git/service.js +3 -1
  95. package/server/lib/llm/DOCUMENTATION.md +6 -4
  96. package/server/lib/llm/generate.js +8 -9
  97. package/server/lib/llm/generate.test.js +33 -6
  98. package/server/lib/message-queue/service.js +1 -1
  99. package/server/lib/opencode/DOCUMENTATION.md +33 -22
  100. package/server/lib/opencode/agents.js +13 -22
  101. package/server/lib/opencode/auth-state-runtime.js +3 -3
  102. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  103. package/server/lib/opencode/auth.d.ts +13 -0
  104. package/server/lib/opencode/auth.js +74 -64
  105. package/server/lib/opencode/auth.test.js +57 -0
  106. package/server/lib/opencode/commands.js +1 -1
  107. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  108. package/server/lib/opencode/config-entity-routes.js +10 -123
  109. package/server/lib/opencode/config-entity-routes.test.js +23 -11
  110. package/server/lib/opencode/config-files.d.ts +17 -0
  111. package/server/lib/opencode/config-files.js +187 -0
  112. package/server/lib/opencode/config-files.test.js +201 -0
  113. package/server/lib/opencode/core-routes.js +17 -1
  114. package/server/lib/opencode/core-routes.test.js +43 -15
  115. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  116. package/server/lib/opencode/credential-boundary.js +11 -0
  117. package/server/lib/opencode/ensure-cli.js +4 -1
  118. package/server/lib/opencode/env-config.js +4 -1
  119. package/server/lib/opencode/env-config.test.js +28 -0
  120. package/server/lib/opencode/env-runtime.test.js +18 -1
  121. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  122. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  123. package/server/lib/opencode/lifecycle.js +1 -49
  124. package/server/lib/opencode/lifecycle.test.js +30 -4
  125. package/server/lib/opencode/location-release.d.ts +8 -0
  126. package/server/lib/opencode/location-release.js +32 -0
  127. package/server/lib/opencode/location-release.test.js +43 -0
  128. package/server/lib/opencode/mcp.js +1 -1
  129. package/server/lib/opencode/opencode2-pin.js +2 -1
  130. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  131. package/server/lib/opencode/plugin-routes.test.js +4 -2
  132. package/server/lib/opencode/plugins.js +12 -60
  133. package/server/lib/opencode/plugins.test.js +5 -3
  134. package/server/lib/opencode/providers.js +6 -3
  135. package/server/lib/opencode/proxy.js +7 -0
  136. package/server/lib/opencode/proxy.test.js +13 -0
  137. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  138. package/server/lib/opencode/routes.js +10 -8
  139. package/server/lib/opencode/routes.test.js +51 -10
  140. package/server/lib/opencode/runtime-contract.js +3 -2
  141. package/server/lib/opencode/runtime-contract.test.js +12 -12
  142. package/server/lib/opencode/settings-normalization-runtime.js +3 -1
  143. package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
  144. package/server/lib/opencode/settings-runtime.test.js +58 -1
  145. package/server/lib/opencode/shared.js +6 -175
  146. package/server/lib/opencode/update-discovery.js +39 -0
  147. package/server/lib/opencode/update-discovery.test.js +39 -0
  148. package/server/lib/opencode/upgrade-screen.js +4 -11
  149. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  150. package/server/lib/opencode/version-alignment.test.js +40 -0
  151. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  152. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  153. package/server/lib/question-auto-delegate/core.js +21 -13
  154. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  155. package/server/lib/question-auto-delegate/form-io.js +39 -0
  156. package/server/lib/question-auto-delegate/runtime.js +4 -24
  157. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  158. package/server/lib/quota/DOCUMENTATION.md +9 -0
  159. package/server/lib/quota/provider-credentials.test.js +29 -0
  160. package/server/lib/quota/providers/claude.js +4 -4
  161. package/server/lib/quota/providers/codex.js +4 -4
  162. package/server/lib/quota/providers/codex.test.js +1 -1
  163. package/server/lib/quota/providers/copilot.js +5 -5
  164. package/server/lib/quota/providers/google/auth.js +3 -3
  165. package/server/lib/quota/providers/google/index.js +2 -2
  166. package/server/lib/quota/providers/index.js +3 -7
  167. package/server/lib/quota/providers/index.test.js +2 -2
  168. package/server/lib/quota/providers/kimi.js +4 -4
  169. package/server/lib/quota/providers/minimax-shared.js +4 -4
  170. package/server/lib/quota/providers/nanogpt.js +4 -4
  171. package/server/lib/quota/providers/openai.js +4 -4
  172. package/server/lib/quota/providers/openrouter.js +4 -4
  173. package/server/lib/quota/providers/wafer.js +4 -4
  174. package/server/lib/quota/providers/zai.js +4 -4
  175. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  176. package/server/lib/quota/routes.js +1 -1
  177. package/server/lib/relay/DOCUMENTATION.md +3 -1
  178. package/server/lib/relay/service.js +1 -0
  179. package/server/lib/relay/service.test.js +13 -0
  180. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  181. package/server/lib/scheduled-tasks/runtime.js +12 -4
  182. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  183. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  184. package/server/lib/session-goal/runtime.js +4 -4
  185. package/server/lib/session-goal/runtime.test.js +32 -3
  186. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  187. package/server/lib/session-index/event-ingest.js +13 -0
  188. package/server/lib/session-index/event-ingest.test.js +7 -0
  189. package/server/lib/session-index/service.js +45 -0
  190. package/server/lib/session-index/service.test.js +47 -0
  191. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  192. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  193. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  194. package/server/lib/session-metadata/session-projection.js +23 -0
  195. package/server/lib/session-metadata/session-projection.test.js +35 -0
  196. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  197. package/server/lib/session-title/native-runtime.test.js +59 -5
  198. package/server/lib/session-title/runtime.js +63 -17
  199. package/server/lib/session-title/session-access.js +108 -3
  200. package/server/lib/session-title/session-access.test.js +125 -7
  201. package/server/lib/tts/DOCUMENTATION.md +7 -5
  202. package/server/lib/tts/credential-source.test.js +23 -0
  203. package/server/lib/tts/routes.js +2 -2
  204. package/server/lib/tts/service.js +16 -37
  205. package/dist/assets/AssistantView-CxAzKd78.js +0 -3
  206. package/dist/assets/Icon-B8fSTLdP.js +0 -1
  207. package/dist/assets/MarkdownRenderer-yq2DPZUG.js +0 -2
  208. package/dist/assets/MobileSurface-qalyCN7X.js +0 -2
  209. package/dist/assets/SettingsView-DVxMbeKC.js +0 -25
  210. package/dist/assets/appThemeRegistry-DC5NRV_i.js +0 -1
  211. package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
  212. package/dist/assets/dialog-CQ63sc9E.js +0 -1
  213. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  214. package/dist/assets/fileMentionTouchSelection-B8Blr6EI.js +0 -3
  215. package/dist/assets/insertionBoundaries-DYu8VdgE.js +0 -1
  216. package/dist/assets/main-D2BzpnUt.css +0 -1
  217. package/dist/assets/main-SXMSe99Y.js +0 -169
  218. package/dist/assets/miniChat-CIi4PmQO.js +0 -2
  219. package/dist/assets/mobile-BK0Ov-k5.js +0 -2
  220. package/dist/assets/multirun-UIu_EbC2.js +0 -4
  221. package/dist/assets/persistence-D0y7226-.js +0 -3
  222. package/dist/assets/pl-BCf5uLlK.js +0 -15
  223. package/dist/assets/platform-D_c-T5nO.js +0 -6
  224. package/dist/assets/projectMeta-BPi16qGY.js +0 -3
  225. package/dist/assets/renderElectronMiniChatApp-CnTCJpBd.js +0 -2
  226. package/dist/assets/renderMobileApp-BFDrOvfv.js +0 -28
  227. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  228. package/dist/assets/runtimeConfig-023C-il6.js +0 -2
  229. package/dist/assets/runtimeEndpointReset-Bdx3CutT.js +0 -149
  230. package/dist/assets/sessionChangeSummary-B8xrgW5-.js +0 -2
  231. package/dist/assets/src-lS1keDVV.css +0 -2
  232. package/dist/assets/useDesktopWindowControlsLayout-kZ4wtPnO.js +0 -1
  233. package/dist/assets/useMobileNavigationStore-BDXu5FSG.js +0 -414
  234. package/dist/assets/useSessionAutoCleanup-BHloMhRp.js +0 -2
  235. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  236. package/server/lib/opencode/snippets.js +0 -242
  237. 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,
@@ -679,112 +674,4 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
679
674
  }
680
675
  });
681
676
 
682
- app.get('/api/config/snippets', async (req, res) => {
683
- try {
684
- const { directory, error } = await resolveOptionalProjectDirectory(req);
685
- if (error) {
686
- return res.status(400).json({ error });
687
- }
688
- res.json(listSnippets(directory));
689
- } catch (error) {
690
- console.error('[API:GET /api/config/snippets] Failed:', error);
691
- res.status(500).json({ error: error.message || 'Failed to list snippets' });
692
- }
693
- });
694
-
695
- app.post('/api/config/snippets/expand', async (req, res) => {
696
- try {
697
- const { directory, error } = await resolveOptionalProjectDirectory(req);
698
- if (error) {
699
- return res.status(400).json({ error });
700
- }
701
- res.json({ text: expandSnippets(req.body?.text ?? '', directory) });
702
- } catch (error) {
703
- console.error('[API:POST /api/config/snippets/expand] Failed:', error);
704
- res.status(500).json({ error: error.message || 'Failed to expand snippets' });
705
- }
706
- });
707
-
708
- app.get('/api/config/snippets/:name', async (req, res) => {
709
- try {
710
- const name = req.params.name;
711
- const { directory, error } = await resolveOptionalProjectDirectory(req);
712
- if (error) {
713
- return res.status(400).json({ error });
714
- }
715
- const snippet = getSnippet(name, directory);
716
- if (!snippet) {
717
- return res.status(404).json({ error: `Snippet "${name}" not found` });
718
- }
719
- res.json(snippet);
720
- } catch (error) {
721
- console.error('[API:GET /api/config/snippets/:name] Failed:', error);
722
- if (error.message?.includes('Snippet name')) {
723
- return res.status(400).json({ error: error.message });
724
- }
725
- res.status(500).json({ error: error.message || 'Failed to get snippet' });
726
- }
727
- });
728
-
729
- app.post('/api/config/snippets/:name', async (req, res) => {
730
- try {
731
- const name = req.params.name;
732
- const { directory, error } = await resolveOptionalProjectDirectory(req);
733
- if (error) {
734
- return res.status(400).json({ error });
735
- }
736
- const snippet = createSnippet(name, req.body || {}, directory, req.body?.scope || 'global');
737
- res.json({ success: true, snippet });
738
- } catch (error) {
739
- console.error('[API:POST /api/config/snippets/:name] Failed:', error);
740
- if (error.message?.includes('already exists')) {
741
- return res.status(409).json({ error: error.message });
742
- }
743
- if (error.message?.includes('Snippet name') || error.message?.includes('Project directory')) {
744
- return res.status(400).json({ error: error.message });
745
- }
746
- res.status(500).json({ error: error.message || 'Failed to create snippet' });
747
- }
748
- });
749
-
750
- app.patch('/api/config/snippets/:name', async (req, res) => {
751
- try {
752
- const name = req.params.name;
753
- const { directory, error } = await resolveOptionalProjectDirectory(req);
754
- if (error) {
755
- return res.status(400).json({ error });
756
- }
757
- res.json({ success: true, snippet: updateSnippet(name, req.body || {}, directory) });
758
- } catch (error) {
759
- console.error('[API:PATCH /api/config/snippets/:name] Failed:', error);
760
- if (error.message?.includes('not found')) {
761
- return res.status(404).json({ error: error.message });
762
- }
763
- if (error.message?.includes('Snippet name')) {
764
- return res.status(400).json({ error: error.message });
765
- }
766
- res.status(500).json({ error: error.message || 'Failed to update snippet' });
767
- }
768
- });
769
-
770
- app.delete('/api/config/snippets/:name', async (req, res) => {
771
- try {
772
- const name = req.params.name;
773
- const { directory, error } = await resolveOptionalProjectDirectory(req);
774
- if (error) {
775
- return res.status(400).json({ error });
776
- }
777
- deleteSnippet(name, directory);
778
- res.json({ success: true });
779
- } catch (error) {
780
- console.error('[API:DELETE /api/config/snippets/:name] Failed:', error);
781
- if (error.message?.includes('not found')) {
782
- return res.status(404).json({ error: error.message });
783
- }
784
- if (error.message?.includes('Snippet name')) {
785
- return res.status(400).json({ error: error.message });
786
- }
787
- res.status(500).json({ error: error.message || 'Failed to delete snippet' });
788
- }
789
- });
790
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' }),
@@ -211,6 +205,22 @@ describe('config entity command metadata route', () => {
211
205
  });
212
206
 
213
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
+
214
224
  it('uses the resolved directory, OpenCode auth, SDK providers call, and explicit catalog route', async () => {
215
225
  const app = express();
216
226
  const resolveProjectDirectory = vi.fn(async () => ({ directory: '/project/from-header' }));
@@ -443,6 +453,8 @@ describe('config entity agent metadata route', () => {
443
453
  });
444
454
 
445
455
  describe('global raw configuration routes', () => {
456
+ beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
457
+ afterEach(() => vi.unstubAllEnvs());
446
458
  it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
447
459
  const app = express();
448
460
  app.use(express.json());
@@ -487,7 +499,7 @@ describe('global raw configuration routes', () => {
487
499
  await fs.rm(configDirectory, { recursive: true, force: true });
488
500
  });
489
501
 
490
- 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 () => {
491
503
  const app = express();
492
504
  app.use(express.json());
493
505
  const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
@@ -500,20 +512,20 @@ describe('global raw configuration routes', () => {
500
512
  const available = await request(app).get('/api/config/global').expect(200);
501
513
  expect(available.body).toEqual({
502
514
  targets: [
503
- { target: 'opencode', fileName: 'opencode.json' },
515
+ { target: 'opencode', fileName: 'opencode.jsonc' },
504
516
  { target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
505
517
  ],
506
518
  });
507
519
 
508
520
  const openCode = await request(app).get('/api/config/global/opencode').expect(200);
509
- expect(openCode.body).toMatchObject({ fileName: 'opencode.json', content: '{ "model": "json-model" }' });
521
+ expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
510
522
 
511
523
  await request(app)
512
524
  .put('/api/config/global/opencode')
513
525
  .send({ content: '{ "model": "updated-json-model" }' })
514
526
  .expect(200);
515
- expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('updated-json-model');
516
- 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');
517
529
 
518
530
  await fs.rm(configDirectory, { recursive: true, force: true });
519
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
+ }