@openchambery/web 2.0.0-beta.25 → 2.0.0-beta.29

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 (194) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-O2x_1cFU.js} +1 -1
  3. package/dist/assets/{AssistantView-CUqIFFyH.js → AssistantView-Bphg-QIn.js} +3 -3
  4. package/dist/assets/{DiagramView-DhEXKsIH.js → DiagramView-CRD_GFch.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
  6. package/dist/assets/{Icon-D7NdMmbi.js → Icon-BN87CyS4.js} +1 -1
  7. package/dist/assets/{MarkdownRenderer-CyacU8IE.js → MarkdownRenderer-D5KEGdBn.js} +2 -2
  8. package/dist/assets/{MarkdownRendererImpl-DgO-73sa.js → MarkdownRendererImpl-C143YQsV.js} +2 -2
  9. package/dist/assets/{MarkstreamRendererImpl-CZPmsOrD.js → MarkstreamRendererImpl-C4rgOuOP.js} +1 -1
  10. package/dist/assets/{MobileDetailNavigation-BGKiZT4X.js → MobileDetailNavigation-D5tcF2Kg.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-BFkZ-if6.js → MobileShareBridge-Bm0JZKHc.js} +3 -3
  12. package/dist/assets/MobileSurface-BzzBCVkG.js +2 -0
  13. package/dist/assets/{MultiRunWindow-BZLr1op3.js → MultiRunWindow-B6Sb3aEG.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-BLjZIBOs.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BOqRklf0.js} +1 -1
  16. package/dist/assets/SettingsView-Ds6x2z0F.js +25 -0
  17. package/dist/assets/{SettingsWindow-BMISHpTN.js → SettingsWindow-D6CN5ahJ.js} +1 -1
  18. package/dist/assets/{TerminalView-DKCcoHzi.js → TerminalView-DkzaJJfh.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-ByptQgFf.js → ToolOutputDialog-BegFpg1h.js} +1 -1
  20. package/dist/assets/{appThemeRegistry-b59_2rLn.js → appThemeRegistry-BMzuqD8_.js} +1 -1
  21. package/dist/assets/{button-e8fYWIMM.js → button-Ck8ipzBm.js} +1 -1
  22. package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-nU99prkD.js} +1 -1
  23. package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DMzZN1oi.js} +1 -1
  24. package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-DuftEj8B.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-g378BdZf.js} +1 -1
  26. package/dist/assets/{diagram-BBL-LfsC.js → diagram-CvO4P8ds.js} +1 -1
  27. package/dist/assets/{dialog-MRsJpHzu.js → dialog-ByGUkS3c.js} +1 -1
  28. package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
  29. package/dist/assets/fileMentionTouchSelection-Cuk9C_jN.js +2 -0
  30. package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-DuF89nFO.js} +1 -1
  31. package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
  32. package/dist/assets/{gitApi-CYFnq-nn.js → gitApi-BlWYyRdx.js} +3 -3
  33. package/dist/assets/{input-DbOe1mdT.js → input-CXhFmHcq.js} +1 -1
  34. package/dist/assets/insertionBoundaries-lAwXo-R-.js +1 -0
  35. package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
  36. package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
  37. package/dist/assets/{main-DOpUCN5f.js → main-BmiKRvkB.js} +2 -2
  38. package/dist/assets/main-D5SdJE5d.js +169 -0
  39. package/dist/assets/main-qSeeJRW1.css +1 -0
  40. package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-g_UsWzbA.js} +1 -1
  41. package/dist/assets/{mcpQueries-Bxq_6P-n.js → mcpQueries-0Oe-8vHC.js} +1 -1
  42. package/dist/assets/{miniChat-CBpEIoJn.js → miniChat-CSwFHa9K.js} +2 -2
  43. package/dist/assets/mobile-DzJO7usb.js +2 -0
  44. package/dist/assets/multirun-Bdd0yE3M.js +3 -0
  45. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  46. package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
  47. package/dist/assets/platform-pfYy32vr.js +6 -0
  48. package/dist/assets/projectMeta-2dbON46t.js +1 -0
  49. package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
  50. package/dist/assets/renderElectronMiniChatApp-Bjf5yS-2.js +2 -0
  51. package/dist/assets/renderMobileApp-Bv0VxQo1.js +28 -0
  52. package/dist/assets/{runtimeConfig-CgFmzQWA.js → runtimeConfig-BOk04YEP.js} +1 -1
  53. package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
  54. package/dist/assets/runtimeEndpointReset-Oo4f7T5P.js +148 -0
  55. package/dist/assets/{sessionChangeSummary-Bg9UCEjA.js → sessionChangeSummary-Bf5wJLSv.js} +2 -2
  56. package/dist/assets/{sessionLookup-4MZgvAtH.js → sessionLookup-pga3-Etf.js} +1 -1
  57. package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-CHqMkTI3.js} +1 -1
  58. package/dist/assets/{store-D8fmTWK3.js → store-Cuktref-.js} +3 -3
  59. package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-BgGraKxs.js} +1 -1
  60. package/dist/assets/{textarea-B5GMntHk.js → textarea-B0Od84ZM.js} +1 -1
  61. package/dist/assets/{tooltip-C64SdRvR.js → tooltip-BKL7J5hx.js} +1 -1
  62. package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
  63. package/dist/assets/{url-DF1NFwt_.js → url-DNRMq_0R.js} +1 -1
  64. package/dist/assets/{useAppFontEffects-Ykf_fh2W.js → useAppFontEffects-gMl7z7An.js} +53 -53
  65. package/dist/assets/{useAssistantUIStore-B75cQm_z.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  66. package/dist/assets/useDesktopWindowControlsLayout-GOgauAay.js +1 -0
  67. package/dist/assets/{useEffectiveDirectory-CpsCUPDZ.js → useEffectiveDirectory-DgAB7HWq.js} +1 -1
  68. package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-Djxu_uRy.js} +1 -1
  69. package/dist/assets/useMobileNavigationStore-D7uAoBXe.js +415 -0
  70. package/dist/assets/useSessionAutoCleanup-Cg7KJFGa.js +2 -0
  71. package/dist/assets/{useUIStore-Brsa2mKs.js → useUIStore-Dfx6TKvi.js} +1 -1
  72. package/dist/assets/{useWorkerHighlightedLines-N119bHfO.js → useWorkerHighlightedLines-DKa2EWBH.js} +1 -1
  73. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  74. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  75. package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
  76. package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
  77. package/dist/index.html +10 -10
  78. package/dist/mini-chat.html +10 -10
  79. package/dist/mobile.html +10 -10
  80. package/package.json +3 -3
  81. package/server/index.js +8 -20
  82. package/server/lib/git/DOCUMENTATION.md +1 -0
  83. package/server/lib/git/routes.js +7 -4
  84. package/server/lib/git/routes.test.js +34 -0
  85. package/server/lib/git/service.js +3 -1
  86. package/server/lib/llm/DOCUMENTATION.md +6 -4
  87. package/server/lib/llm/generate.js +8 -9
  88. package/server/lib/llm/generate.test.js +33 -6
  89. package/server/lib/opencode/DOCUMENTATION.md +24 -20
  90. package/server/lib/opencode/agents.js +13 -22
  91. package/server/lib/opencode/auth-state-runtime.js +3 -3
  92. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  93. package/server/lib/opencode/auth.d.ts +13 -0
  94. package/server/lib/opencode/auth.js +74 -64
  95. package/server/lib/opencode/auth.test.js +57 -0
  96. package/server/lib/opencode/commands.js +1 -1
  97. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  98. package/server/lib/opencode/config-entity-routes.js +9 -122
  99. package/server/lib/opencode/config-entity-routes.test.js +7 -11
  100. package/server/lib/opencode/config-files.d.ts +17 -0
  101. package/server/lib/opencode/config-files.js +187 -0
  102. package/server/lib/opencode/config-files.test.js +201 -0
  103. package/server/lib/opencode/core-routes.js +0 -1
  104. package/server/lib/opencode/core-routes.test.js +0 -15
  105. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  106. package/server/lib/opencode/credential-boundary.js +11 -0
  107. package/server/lib/opencode/ensure-cli.js +4 -1
  108. package/server/lib/opencode/env-config.js +4 -1
  109. package/server/lib/opencode/env-config.test.js +28 -0
  110. package/server/lib/opencode/env-runtime.test.js +2 -1
  111. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  112. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  113. package/server/lib/opencode/lifecycle.js +1 -49
  114. package/server/lib/opencode/lifecycle.test.js +30 -4
  115. package/server/lib/opencode/location-release.d.ts +8 -0
  116. package/server/lib/opencode/location-release.js +32 -0
  117. package/server/lib/opencode/location-release.test.js +43 -0
  118. package/server/lib/opencode/mcp.js +1 -1
  119. package/server/lib/opencode/opencode2-pin.js +2 -1
  120. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  121. package/server/lib/opencode/plugin-routes.test.js +4 -2
  122. package/server/lib/opencode/plugins.js +12 -60
  123. package/server/lib/opencode/plugins.test.js +5 -3
  124. package/server/lib/opencode/providers.js +6 -3
  125. package/server/lib/opencode/proxy.js +7 -0
  126. package/server/lib/opencode/proxy.test.js +13 -0
  127. package/server/lib/opencode/routes.js +5 -5
  128. package/server/lib/opencode/routes.test.js +18 -18
  129. package/server/lib/opencode/runtime-contract.js +3 -2
  130. package/server/lib/opencode/runtime-contract.test.js +12 -12
  131. package/server/lib/opencode/shared.js +6 -175
  132. package/server/lib/opencode/upgrade-screen.js +4 -11
  133. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  134. package/server/lib/opencode/version-alignment.test.js +40 -0
  135. package/server/lib/quota/DOCUMENTATION.md +9 -0
  136. package/server/lib/quota/provider-credentials.test.js +29 -0
  137. package/server/lib/quota/providers/claude.js +4 -4
  138. package/server/lib/quota/providers/codex.js +4 -4
  139. package/server/lib/quota/providers/codex.test.js +1 -1
  140. package/server/lib/quota/providers/copilot.js +5 -5
  141. package/server/lib/quota/providers/google/auth.js +3 -3
  142. package/server/lib/quota/providers/google/index.js +2 -2
  143. package/server/lib/quota/providers/index.js +3 -7
  144. package/server/lib/quota/providers/index.test.js +2 -2
  145. package/server/lib/quota/providers/kimi.js +4 -4
  146. package/server/lib/quota/providers/minimax-shared.js +4 -4
  147. package/server/lib/quota/providers/nanogpt.js +4 -4
  148. package/server/lib/quota/providers/openai.js +4 -4
  149. package/server/lib/quota/providers/openrouter.js +4 -4
  150. package/server/lib/quota/providers/wafer.js +4 -4
  151. package/server/lib/quota/providers/zai.js +4 -4
  152. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  153. package/server/lib/quota/routes.js +1 -1
  154. package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
  155. package/server/lib/scheduled-tasks/runtime.js +2 -3
  156. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  157. package/server/lib/session-index/event-ingest.js +13 -0
  158. package/server/lib/session-index/event-ingest.test.js +7 -0
  159. package/server/lib/session-index/service.js +45 -0
  160. package/server/lib/session-index/service.test.js +47 -0
  161. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  162. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  163. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  164. package/server/lib/session-metadata/session-projection.js +23 -0
  165. package/server/lib/session-metadata/session-projection.test.js +35 -0
  166. package/server/lib/session-title/DOCUMENTATION.md +36 -8
  167. package/server/lib/session-title/native-runtime.test.js +42 -0
  168. package/server/lib/session-title/runtime.js +18 -8
  169. package/server/lib/session-title/session-access.js +108 -3
  170. package/server/lib/session-title/session-access.test.js +125 -7
  171. package/server/lib/tts/DOCUMENTATION.md +7 -5
  172. package/server/lib/tts/credential-source.test.js +23 -0
  173. package/server/lib/tts/routes.js +2 -2
  174. package/server/lib/tts/service.js +16 -37
  175. package/dist/assets/MobileSurface-CiQ9yAML.js +0 -2
  176. package/dist/assets/SettingsView-Bo1Xj4mZ.js +0 -25
  177. package/dist/assets/fileMentionTouchSelection-CMG5yrcJ.js +0 -3
  178. package/dist/assets/insertionBoundaries-C5b2f_mO.js +0 -1
  179. package/dist/assets/main-Bz3MB9dk.js +0 -169
  180. package/dist/assets/main-D2BzpnUt.css +0 -1
  181. package/dist/assets/mobile-CiFeGY2g.js +0 -2
  182. package/dist/assets/multirun-DnD1iWqj.js +0 -4
  183. package/dist/assets/persistence-CMWO3jFo.js +0 -3
  184. package/dist/assets/platform-B0p43squ.js +0 -6
  185. package/dist/assets/projectMeta-B1ShmM7W.js +0 -3
  186. package/dist/assets/renderElectronMiniChatApp-DePrSFtn.js +0 -2
  187. package/dist/assets/renderMobileApp-CiGHeia2.js +0 -28
  188. package/dist/assets/runtimeEndpointReset-Dw46SzSQ.js +0 -149
  189. package/dist/assets/useDesktopWindowControlsLayout-kH_CrZfG.js +0 -1
  190. package/dist/assets/useMobileNavigationStore-CBo6pohX.js +0 -414
  191. package/dist/assets/useSessionAutoCleanup-BuFi27vR.js +0 -2
  192. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  193. package/server/lib/opencode/snippets.js +0 -242
  194. 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) => {
@@ -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' }),
@@ -459,6 +453,8 @@ describe('config entity agent metadata route', () => {
459
453
  });
460
454
 
461
455
  describe('global raw configuration routes', () => {
456
+ beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
457
+ afterEach(() => vi.unstubAllEnvs());
462
458
  it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
463
459
  const app = express();
464
460
  app.use(express.json());
@@ -503,7 +499,7 @@ describe('global raw configuration routes', () => {
503
499
  await fs.rm(configDirectory, { recursive: true, force: true });
504
500
  });
505
501
 
506
- 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 () => {
507
503
  const app = express();
508
504
  app.use(express.json());
509
505
  const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
@@ -516,20 +512,20 @@ describe('global raw configuration routes', () => {
516
512
  const available = await request(app).get('/api/config/global').expect(200);
517
513
  expect(available.body).toEqual({
518
514
  targets: [
519
- { target: 'opencode', fileName: 'opencode.json' },
515
+ { target: 'opencode', fileName: 'opencode.jsonc' },
520
516
  { target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
521
517
  ],
522
518
  });
523
519
 
524
520
  const openCode = await request(app).get('/api/config/global/opencode').expect(200);
525
- expect(openCode.body).toMatchObject({ fileName: 'opencode.json', content: '{ "model": "json-model" }' });
521
+ expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
526
522
 
527
523
  await request(app)
528
524
  .put('/api/config/global/opencode')
529
525
  .send({ content: '{ "model": "updated-json-model" }' })
530
526
  .expect(200);
531
- expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('updated-json-model');
532
- 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');
533
529
 
534
530
  await fs.rm(configDirectory, { recursive: true, force: true });
535
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
+ }