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

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 (175) hide show
  1. package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-CqD6Pfom.js} +1 -1
  2. package/dist/assets/{AssistantView-CUqIFFyH.js → AssistantView-BW9f9vF2.js} +3 -3
  3. package/dist/assets/{DiagramView-DhEXKsIH.js → DiagramView-BP3WDmFe.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-ezW_e-yz.js} +1 -1
  5. package/dist/assets/{Icon-D7NdMmbi.js → Icon-ChOAx1jX.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-CyacU8IE.js → MarkdownRenderer-BBHZa1Ts.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-DgO-73sa.js → MarkdownRendererImpl-DfxEsttZ.js} +2 -2
  8. package/dist/assets/{MarkstreamRendererImpl-CZPmsOrD.js → MarkstreamRendererImpl-6k-n071l.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-BGKiZT4X.js → MobileDetailNavigation-s48GkQY9.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BFkZ-if6.js → MobileShareBridge-DAgShkFE.js} +2 -2
  11. package/dist/assets/MobileSurface-DZug2f4O.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BZLr1op3.js → MultiRunWindow-CIhHw9mo.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-ozxwbzDw.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BL-QnnrN.js} +1 -1
  15. package/dist/assets/SettingsView-DmUdWSji.js +25 -0
  16. package/dist/assets/{SettingsWindow-BMISHpTN.js → SettingsWindow-CuIZwGhM.js} +1 -1
  17. package/dist/assets/{TerminalView-DKCcoHzi.js → TerminalView-BIJ-ILgl.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-ByptQgFf.js → ToolOutputDialog-B6KIxVUo.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-b59_2rLn.js → appThemeRegistry-BuMGUGUK.js} +1 -1
  20. package/dist/assets/{button-e8fYWIMM.js → button-DoDvdeG-.js} +1 -1
  21. package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-CZkyFVK0.js} +1 -1
  22. package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DTjON-Ta.js} +1 -1
  23. package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-CP1YHQ4Z.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-BeOGO7oA.js} +1 -1
  25. package/dist/assets/{diagram-BBL-LfsC.js → diagram-DUi801df.js} +1 -1
  26. package/dist/assets/{dialog-MRsJpHzu.js → dialog-DGzGqOhG.js} +1 -1
  27. package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
  28. package/dist/assets/fileMentionTouchSelection-C3A6XKRM.js +2 -0
  29. package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-CAH8-p27.js} +1 -1
  30. package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
  31. package/dist/assets/{gitApi-CYFnq-nn.js → gitApi-B1Nyw0PD.js} +3 -3
  32. package/dist/assets/{input-DbOe1mdT.js → input-Ct2uaiDc.js} +1 -1
  33. package/dist/assets/insertionBoundaries-BypTPUfp.js +1 -0
  34. package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
  35. package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
  36. package/dist/assets/main-BREqmQUm.js +169 -0
  37. package/dist/assets/{main-DOpUCN5f.js → main-BodZWzei.js} +2 -2
  38. package/dist/assets/main-qSeeJRW1.css +1 -0
  39. package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-CMoWScss.js} +1 -1
  40. package/dist/assets/{mcpQueries-Bxq_6P-n.js → mcpQueries-nScpWEqu.js} +1 -1
  41. package/dist/assets/{miniChat-CBpEIoJn.js → miniChat-Ca-kdWf0.js} +2 -2
  42. package/dist/assets/{mobile-CiFeGY2g.js → mobile-BWi6F8Lj.js} +2 -2
  43. package/dist/assets/multirun-BdSrR6jN.js +3 -0
  44. package/dist/assets/persistence-B3_CPgm1.js +3 -0
  45. package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
  46. package/dist/assets/platform-Bxdc4SbV.js +6 -0
  47. package/dist/assets/projectMeta-CIKLMweH.js +1 -0
  48. package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
  49. package/dist/assets/{renderElectronMiniChatApp-DePrSFtn.js → renderElectronMiniChatApp-DtYCIW4n.js} +2 -2
  50. package/dist/assets/renderMobileApp-D47vojsP.js +28 -0
  51. package/dist/assets/{runtimeConfig-CgFmzQWA.js → runtimeConfig-u9DrZmkS.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-Bn4vJ-gl.js +148 -0
  53. package/dist/assets/runtimeEndpointReset-D1_Y5I4F.css +1 -0
  54. package/dist/assets/{sessionChangeSummary-Bg9UCEjA.js → sessionChangeSummary-C0OxmHed.js} +2 -2
  55. package/dist/assets/{sessionLookup-4MZgvAtH.js → sessionLookup-D1NHRgWf.js} +1 -1
  56. package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-Cp5i5hI-.js} +1 -1
  57. package/dist/assets/{store-D8fmTWK3.js → store-BTqPn8Uq.js} +3 -3
  58. package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-CqyW3_W3.js} +1 -1
  59. package/dist/assets/{textarea-B5GMntHk.js → textarea-jEbc6gqf.js} +1 -1
  60. package/dist/assets/{tooltip-C64SdRvR.js → tooltip-DKYT7xYe.js} +1 -1
  61. package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
  62. package/dist/assets/{url-DF1NFwt_.js → url-hhmiocZY.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-Ykf_fh2W.js → useAppFontEffects-BBX1KjLV.js} +53 -53
  64. package/dist/assets/{useDesktopWindowControlsLayout-kH_CrZfG.js → useDesktopWindowControlsLayout-uKYzUX-m.js} +1 -1
  65. package/dist/assets/{useEffectiveDirectory-CpsCUPDZ.js → useEffectiveDirectory-BOolveMp.js} +1 -1
  66. package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-BMJQP6t6.js} +1 -1
  67. package/dist/assets/useMobileNavigationStore-CC8QT7VC.js +415 -0
  68. package/dist/assets/useSessionAutoCleanup-CydMAlLf.js +2 -0
  69. package/dist/assets/{useWorkerHighlightedLines-N119bHfO.js → useWorkerHighlightedLines-BF1ClmMe.js} +1 -1
  70. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  71. package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
  72. package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +3 -3
  77. package/server/index.js +2 -20
  78. package/server/lib/git/DOCUMENTATION.md +1 -0
  79. package/server/lib/git/routes.js +7 -4
  80. package/server/lib/git/routes.test.js +34 -0
  81. package/server/lib/git/service.js +3 -1
  82. package/server/lib/llm/DOCUMENTATION.md +6 -4
  83. package/server/lib/llm/generate.js +8 -9
  84. package/server/lib/llm/generate.test.js +33 -6
  85. package/server/lib/opencode/DOCUMENTATION.md +24 -20
  86. package/server/lib/opencode/agents.js +13 -22
  87. package/server/lib/opencode/auth-state-runtime.js +3 -3
  88. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  89. package/server/lib/opencode/auth.d.ts +13 -0
  90. package/server/lib/opencode/auth.js +74 -64
  91. package/server/lib/opencode/auth.test.js +57 -0
  92. package/server/lib/opencode/commands.js +1 -1
  93. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  94. package/server/lib/opencode/config-entity-routes.js +9 -122
  95. package/server/lib/opencode/config-entity-routes.test.js +7 -11
  96. package/server/lib/opencode/config-files.d.ts +17 -0
  97. package/server/lib/opencode/config-files.js +187 -0
  98. package/server/lib/opencode/config-files.test.js +201 -0
  99. package/server/lib/opencode/core-routes.js +0 -1
  100. package/server/lib/opencode/core-routes.test.js +0 -15
  101. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  102. package/server/lib/opencode/credential-boundary.js +11 -0
  103. package/server/lib/opencode/ensure-cli.js +4 -1
  104. package/server/lib/opencode/env-config.js +4 -1
  105. package/server/lib/opencode/env-config.test.js +28 -0
  106. package/server/lib/opencode/env-runtime.test.js +2 -1
  107. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  108. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  109. package/server/lib/opencode/lifecycle.js +1 -49
  110. package/server/lib/opencode/lifecycle.test.js +30 -4
  111. package/server/lib/opencode/location-release.d.ts +8 -0
  112. package/server/lib/opencode/location-release.js +32 -0
  113. package/server/lib/opencode/location-release.test.js +43 -0
  114. package/server/lib/opencode/mcp.js +1 -1
  115. package/server/lib/opencode/opencode2-pin.js +2 -1
  116. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  117. package/server/lib/opencode/plugins.js +12 -60
  118. package/server/lib/opencode/providers.js +6 -3
  119. package/server/lib/opencode/proxy.js +7 -0
  120. package/server/lib/opencode/proxy.test.js +13 -0
  121. package/server/lib/opencode/routes.js +5 -5
  122. package/server/lib/opencode/routes.test.js +18 -18
  123. package/server/lib/opencode/runtime-contract.js +3 -2
  124. package/server/lib/opencode/runtime-contract.test.js +12 -12
  125. package/server/lib/opencode/shared.js +6 -175
  126. package/server/lib/opencode/upgrade-screen.js +4 -11
  127. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  128. package/server/lib/opencode/version-alignment.test.js +40 -0
  129. package/server/lib/quota/DOCUMENTATION.md +9 -0
  130. package/server/lib/quota/provider-credentials.test.js +29 -0
  131. package/server/lib/quota/providers/claude.js +4 -4
  132. package/server/lib/quota/providers/codex.js +4 -4
  133. package/server/lib/quota/providers/codex.test.js +1 -1
  134. package/server/lib/quota/providers/copilot.js +5 -5
  135. package/server/lib/quota/providers/google/auth.js +3 -3
  136. package/server/lib/quota/providers/google/index.js +2 -2
  137. package/server/lib/quota/providers/index.js +3 -7
  138. package/server/lib/quota/providers/index.test.js +2 -2
  139. package/server/lib/quota/providers/kimi.js +4 -4
  140. package/server/lib/quota/providers/minimax-shared.js +4 -4
  141. package/server/lib/quota/providers/nanogpt.js +4 -4
  142. package/server/lib/quota/providers/openai.js +4 -4
  143. package/server/lib/quota/providers/openrouter.js +4 -4
  144. package/server/lib/quota/providers/wafer.js +4 -4
  145. package/server/lib/quota/providers/zai.js +4 -4
  146. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  147. package/server/lib/quota/routes.js +1 -1
  148. package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
  149. package/server/lib/scheduled-tasks/runtime.js +2 -3
  150. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  151. package/server/lib/session-index/event-ingest.js +13 -0
  152. package/server/lib/session-index/event-ingest.test.js +7 -0
  153. package/server/lib/session-index/service.js +45 -0
  154. package/server/lib/session-index/service.test.js +47 -0
  155. package/server/lib/tts/DOCUMENTATION.md +7 -5
  156. package/server/lib/tts/credential-source.test.js +23 -0
  157. package/server/lib/tts/routes.js +2 -2
  158. package/server/lib/tts/service.js +16 -37
  159. package/dist/assets/MobileSurface-CiQ9yAML.js +0 -2
  160. package/dist/assets/SettingsView-Bo1Xj4mZ.js +0 -25
  161. package/dist/assets/fileMentionTouchSelection-CMG5yrcJ.js +0 -3
  162. package/dist/assets/insertionBoundaries-C5b2f_mO.js +0 -1
  163. package/dist/assets/main-Bz3MB9dk.js +0 -169
  164. package/dist/assets/main-D2BzpnUt.css +0 -1
  165. package/dist/assets/multirun-DnD1iWqj.js +0 -4
  166. package/dist/assets/persistence-CMWO3jFo.js +0 -3
  167. package/dist/assets/platform-B0p43squ.js +0 -6
  168. package/dist/assets/projectMeta-B1ShmM7W.js +0 -3
  169. package/dist/assets/renderMobileApp-CiGHeia2.js +0 -28
  170. package/dist/assets/runtimeEndpointReset-Dw46SzSQ.js +0 -149
  171. package/dist/assets/useMobileNavigationStore-CBo6pohX.js +0 -414
  172. package/dist/assets/useSessionAutoCleanup-BuFi27vR.js +0 -2
  173. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  174. package/server/lib/opencode/snippets.js +0 -242
  175. package/server/lib/opencode/snippets.test.js +0 -97
@@ -197,23 +197,12 @@ function getAgentPermissionSource(agentName, workingDirectory, lookupCache = nul
197
197
  }
198
198
  }
199
199
 
200
- // Check JSON layers in effective override order. readConfigLayers merges
201
- // user -> project -> custom, so custom wins over project, project over user.
200
+ // Check physical documents in effective override order, including both global files.
202
201
  const layers = readConfigLayers(workingDirectory);
203
-
204
- const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
205
- if (customJsonPermission !== undefined && layers.paths.customPath) {
206
- return { source: 'json', scope: 'custom', path: layers.paths.customPath };
207
- }
208
-
209
- const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
210
- if (projectJsonPermission !== undefined && layers.paths.projectPath) {
211
- return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
212
- }
213
-
214
- const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
215
- if (userJsonPermission !== undefined) {
216
- return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
202
+ for (const layer of layers.documents.toReversed()) {
203
+ if (layer.config?.agent?.[agentName]?.permission !== undefined) {
204
+ return { source: 'json', scope: layer.scope, path: layer.path };
205
+ }
217
206
  }
218
207
 
219
208
  return { source: null, scope: null, path: null };
@@ -300,7 +289,7 @@ function getAgentSources(agentName, workingDirectory, lookupCache = createAgentL
300
289
  const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
301
290
  const jsonSection = jsonSource.section;
302
291
  const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
303
- const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
292
+ const jsonScope = layers.projectLayers.some((layer) => layer.path === jsonSource.path) ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
304
293
 
305
294
  const sources = {
306
295
  md: {
@@ -444,7 +433,7 @@ function getAgentConfig(agentName, workingDirectory, lookupCache = createAgentLo
444
433
  const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
445
434
 
446
435
  if (jsonSource.exists && jsonSource.section) {
447
- const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
436
+ const scope = layers.projectLayers.some((layer) => layer.path === jsonSource.path) ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
448
437
  return {
449
438
  source: 'json',
450
439
  scope,
@@ -756,8 +745,9 @@ function deleteAgent(agentName, workingDirectory, scope) {
756
745
  const layers = readConfigLayers(workingDirectory);
757
746
 
758
747
  if (requestedScope === AGENT_SCOPE.PROJECT) {
759
- if (layers.paths.projectPath && deleteJsonAgentEntry(layers.projectConfig, agentName)) {
760
- writeConfig(layers.projectConfig, layers.paths.projectPath);
748
+ const target = layers.projectLayers.toReversed().find((layer) => layer.config?.agent?.[agentName] !== undefined);
749
+ if (target && deleteJsonAgentEntry(target.config, agentName)) {
750
+ writeConfig(target.config, target.path);
761
751
  console.log(`Removed project-level agent from opencode.json: ${agentName}`);
762
752
  return;
763
753
  }
@@ -765,8 +755,9 @@ function deleteAgent(agentName, workingDirectory, scope) {
765
755
  }
766
756
 
767
757
  if (requestedScope === AGENT_SCOPE.USER) {
768
- const userJsonPath = layers.paths.customPath || layers.paths.userPath;
769
- const userJsonConfig = layers.paths.customPath ? layers.customConfig : layers.userConfig;
758
+ const target = layers.documents.toReversed().find((layer) => layer.scope !== 'project' && layer.config?.agent?.[agentName] !== undefined);
759
+ const userJsonPath = target?.path;
760
+ const userJsonConfig = target?.config;
770
761
  if (userJsonPath && deleteJsonAgentEntry(userJsonConfig, agentName)) {
771
762
  writeConfig(userJsonConfig, userJsonPath);
772
763
  console.log(`Removed user-level agent from opencode.json: ${agentName}`);
@@ -31,7 +31,7 @@ export const createOpenCodeAuthStateRuntime = (dependencies) => {
31
31
  const normalized = normalizeOpenCodePassword(password);
32
32
  if (!isValidOpenCodePassword(normalized)) {
33
33
  setAuthPassword(null);
34
- setAuthSource(null);
34
+ setAuthSource(source || null);
35
35
  delete process.env.OPENCODE_SERVER_PASSWORD;
36
36
  syncToHmrState();
37
37
  return null;
@@ -45,13 +45,13 @@ export const createOpenCodeAuthStateRuntime = (dependencies) => {
45
45
  };
46
46
 
47
47
  const getOpenCodeAuthHeaders = () => {
48
- const password = normalizeOpenCodePassword(getAuthPassword() || process.env.OPENCODE_SERVER_PASSWORD || '');
48
+ const password = normalizeOpenCodePassword(getAuthPassword() || '');
49
49
 
50
50
  if (!password) {
51
51
  return {};
52
52
  }
53
53
 
54
- const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
54
+ const username = getAuthSource() === 'shared-service' ? 'opencode' : process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
55
55
  const credentials = Buffer.from(`${username}:${password}`).toString('base64');
56
56
  return { Authorization: `Basic ${credentials}` };
57
57
  };
@@ -0,0 +1,59 @@
1
+ import crypto from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createOpenCodeAuthStateRuntime } from './auth-state-runtime.js';
4
+ import { createHmrStateRuntime } from './hmr-state-runtime.js';
5
+
6
+ const setup = (env = {}) => {
7
+ const processLike = { env: { ...env } };
8
+ const hmr = createHmrStateRuntime({ globalThisLike: {}, os: { homedir: () => '/example' }, processLike, stateKey: 'test' });
9
+ const state = hmr.getOrCreateHmrState();
10
+ hmr.ensureUserProvidedOpenCodePassword(state);
11
+ const restore = () => hmr.restoreRuntimeFromState({ hmrState: state, userProvidedOpenCodePassword: hmr.getUserProvidedOpenCodePassword(state) });
12
+ const runtimeState = restore();
13
+ const auth = createOpenCodeAuthStateRuntime({ crypto, process: processLike,
14
+ getAuthPassword: () => runtimeState.openCodeAuthPassword,
15
+ setAuthPassword: (value) => { runtimeState.openCodeAuthPassword = value; },
16
+ getAuthSource: () => runtimeState.openCodeAuthSource,
17
+ setAuthSource: (value) => { runtimeState.openCodeAuthSource = value; },
18
+ getUserProvidedPassword: () => hmr.getUserProvidedOpenCodePassword(state),
19
+ syncToHmrState: () => hmr.syncStateFromRuntime(state, runtimeState),
20
+ });
21
+ return { auth, restore, runtimeState, processLike, hmr, state };
22
+ };
23
+
24
+ describe('OpenCode auth ownership', () => {
25
+ it('uses v2 before legacy credentials and preserves the captured choice through HMR/restart', async () => {
26
+ const test = setup({ OPENCODE_PASSWORD: 'v2-fixture', OPENCODE_SERVER_PASSWORD: 'legacy-fixture' });
27
+ const password = await test.auth.ensureLocalOpenCodeServerPassword({ rotateManaged: true });
28
+ expect(password === 'v2-fixture').toBe(true);
29
+ expect(test.auth.getOpenCodeAuthHeaders().Authorization === `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`).toBe(true);
30
+ test.processLike.env.OPENCODE_PASSWORD = 'later-shell-fixture';
31
+ test.hmr.ensureUserProvidedOpenCodePassword(test.state);
32
+ expect((await test.auth.ensureLocalOpenCodeServerPassword({ rotateManaged: true })) === password).toBe(true);
33
+ expect(test.restore().openCodeAuthPassword === password).toBe(true);
34
+ });
35
+
36
+ it('rotates generated credentials while old credentials fail a controlled auth check', async () => {
37
+ const { auth, restore } = setup();
38
+ const old = await auth.ensureLocalOpenCodeServerPassword();
39
+ const previousHeader = auth.getOpenCodeAuthHeaders().Authorization;
40
+ const password = await auth.ensureLocalOpenCodeServerPassword({ rotateManaged: true });
41
+ const accepts = (header) => header === `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`;
42
+ expect(password !== old).toBe(true);
43
+ expect(accepts(previousHeader)).toBe(false);
44
+ expect(accepts(auth.getOpenCodeAuthHeaders().Authorization)).toBe(true);
45
+ expect(restore().openCodeAuthPassword === password).toBe(true);
46
+ });
47
+
48
+ it('adopts only shared registration credentials and never falls back to private env after clearing', () => {
49
+ const { auth, processLike, restore } = setup({ OPENCODE_PASSWORD: 'private-fixture', OPENCODE_SERVER_USERNAME: 'private-user' });
50
+ auth.adoptOpenCodeServerPassword('registration-fixture', 'shared-service');
51
+ expect(auth.getOpenCodeAuthHeaders().Authorization === `Basic ${Buffer.from('opencode:registration-fixture').toString('base64')}`).toBe(true);
52
+ auth.adoptOpenCodeServerPassword(null, 'shared-service');
53
+ expect(restore().openCodeAuthPassword).toBeNull();
54
+ expect(restore().openCodeAuthSource).toBe('shared-service');
55
+ auth.adoptOpenCodeServerPassword(null, null);
56
+ processLike.env.OPENCODE_SERVER_PASSWORD = 'stale-fixture';
57
+ expect(auth.getOpenCodeAuthHeaders()).toEqual({});
58
+ });
59
+ });
@@ -0,0 +1,13 @@
1
+ import type { OpenCodeClient } from '@opencode/client';
2
+
3
+ export type ProviderCredential =
4
+ | { type: 'api'; key: string }
5
+ | { type: 'oauth'; access: string; refresh: string; expires: number; accountId?: string; enterpriseUrl?: string };
6
+ export function configureOpenCodeCredentials(resolveClient: (() => OpenCodeClient) | null): void;
7
+ export function openCodeCredentialSource(deps: {
8
+ buildOpenCodeUrl: (path: string, prefix?: string) => string;
9
+ getOpenCodeAuthHeaders: () => Record<string, string>;
10
+ }): () => OpenCodeClient;
11
+ export function readOpenCodeCredentials(): Promise<Record<string, ProviderCredential>>;
12
+ export function getProviderAuth(providerId: string): Promise<ProviderCredential | null>;
13
+ export function removeProviderAuth(providerId: string): Promise<boolean>;
@@ -1,81 +1,91 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
1
+ import { makeOpenCodeV2Client } from './v2-client.js';
4
2
 
5
- const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
6
- const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
3
+ /** @typedef {{ type: 'api', key: string } | { type: 'oauth', access: string, refresh: string, expires: number, accountId?: string, enterpriseUrl?: string }} ProviderCredential */
4
+ /** @type {null | (() => import('@opencode/client').OpenCodeClient)} */
5
+ let getClient = null;
7
6
 
8
- function readAuthFile() {
9
- if (!fs.existsSync(AUTH_FILE)) {
10
- return {};
11
- }
12
- try {
13
- const content = fs.readFileSync(AUTH_FILE, 'utf8');
14
- const trimmed = content.trim();
15
- if (!trimmed) {
16
- return {};
17
- }
18
- return JSON.parse(trimmed);
19
- } catch (error) {
20
- console.error('Failed to read auth file:', error);
21
- throw new Error('Failed to read OpenCode auth configuration');
22
- }
7
+ /** Configure only in the trusted host. Resolve endpoint and authentication on every call. */
8
+ export function configureOpenCodeCredentials(resolveClient) {
9
+ getClient = resolveClient;
23
10
  }
24
11
 
25
- function writeAuthFile(auth) {
26
- try {
27
- if (!fs.existsSync(OPENCODE_DATA_DIR)) {
28
- fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
29
- }
12
+ export function openCodeCredentialSource({ buildOpenCodeUrl, getOpenCodeAuthHeaders }) {
13
+ return () => makeOpenCodeV2Client({
14
+ baseUrl: buildOpenCodeUrl('', '').replace(/\/+$/, ''),
15
+ authHeaders: getOpenCodeAuthHeaders(),
16
+ });
17
+ }
30
18
 
31
- if (fs.existsSync(AUTH_FILE)) {
32
- const backupFile = `${AUTH_FILE}.openchamber.backup`;
33
- fs.copyFileSync(AUTH_FILE, backupFile);
34
- console.log(`Created auth backup: ${backupFile}`);
35
- }
19
+ const credentialError = () => new Error('Unable to read OpenCode credentials');
20
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
36
21
 
37
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
38
- console.log('Successfully wrote auth file');
39
- } catch (error) {
40
- console.error('Failed to write auth file:', error);
41
- throw new Error('Failed to write OpenCode auth configuration');
22
+ /** Validate before projection: malformed/unsupported responses are never empty success. */
23
+ function validateCredentialEntries(entries) {
24
+ if (!Array.isArray(entries)) throw credentialError();
25
+ const active = new Set();
26
+ for (const entry of entries) {
27
+ if (!record(entry) || typeof entry.id !== 'string' || !entry.id || typeof entry.integrationID !== 'string'
28
+ || !entry.integrationID || typeof entry.active !== 'boolean' || !record(entry.value)) throw credentialError();
29
+ const value = entry.value;
30
+ if (value.type === 'key') {
31
+ if (typeof value.key !== 'string') throw credentialError();
32
+ } else if (value.type === 'oauth') {
33
+ if (typeof value.access !== 'string' || typeof value.refresh !== 'string' || !Number.isFinite(value.expires)) throw credentialError();
34
+ } else throw credentialError();
35
+ if (entry.active) {
36
+ if (active.has(entry.integrationID)) throw credentialError();
37
+ active.add(entry.integrationID);
38
+ }
42
39
  }
40
+ return entries;
43
41
  }
44
42
 
45
- function removeProviderAuth(providerId) {
46
- if (!providerId || typeof providerId !== 'string') {
47
- throw new Error('Provider ID is required');
48
- }
49
-
50
- const auth = readAuthFile();
51
-
52
- if (!auth[providerId]) {
53
- console.log(`Provider ${providerId} not found in auth file, nothing to remove`);
54
- return false;
43
+ async function listCredentials(client) {
44
+ try {
45
+ return validateCredentialEntries(await client.credential.list({ signal: AbortSignal.timeout(8000) }));
46
+ } catch {
47
+ // Upstream errors can contain response bodies or request headers with secrets.
48
+ throw credentialError();
55
49
  }
50
+ }
56
51
 
57
- delete auth[providerId];
58
- writeAuthFile(auth);
59
- console.log(`Removed provider auth: ${providerId}`);
60
- return true;
52
+ function currentClient() {
53
+ if (!getClient) throw credentialError();
54
+ try { return getClient(); } catch { throw credentialError(); }
61
55
  }
62
56
 
63
- function getProviderAuth(providerId) {
64
- const auth = readAuthFile();
65
- return auth[providerId] || null;
57
+ /** Host-only selected provider credentials. Never serialize this result to a client. */
58
+ export async function readOpenCodeCredentials() {
59
+ const entries = await listCredentials(currentClient());
60
+ /** @type {Record<string, ProviderCredential>} */
61
+ const result = Object.create(null);
62
+ for (const entry of entries) {
63
+ if (!entry.active) continue;
64
+ const value = entry.value;
65
+ result[entry.integrationID] = value.type === 'key' ? { type: 'api', key: value.key } : {
66
+ type: 'oauth', access: value.access, refresh: value.refresh, expires: value.expires,
67
+ ...(typeof value.metadata?.accountID === 'string' ? { accountId: value.metadata.accountID } : {}),
68
+ ...(typeof value.metadata?.enterpriseUrl === 'string' ? { enterpriseUrl: value.metadata.enterpriseUrl } : {}),
69
+ };
70
+ }
71
+ return result;
66
72
  }
67
73
 
68
- function listProviderAuths() {
69
- const auth = readAuthFile();
70
- return Object.keys(auth);
74
+ export async function getProviderAuth(providerId) {
75
+ return (await readOpenCodeCredentials())[providerId] || null;
71
76
  }
72
77
 
73
- export {
74
- readAuthFile,
75
- writeAuthFile,
76
- removeProviderAuth,
77
- getProviderAuth,
78
- listProviderAuths,
79
- AUTH_FILE,
80
- OPENCODE_DATA_DIR
81
- };
78
+ /** Disconnect every saved account for the provider, so deleting active cannot select another. */
79
+ export async function removeProviderAuth(providerId) {
80
+ if (typeof providerId !== 'string' || !providerId.trim()) throw new Error('Provider ID is required');
81
+ const client = currentClient();
82
+ const entries = (await listCredentials(client)).filter((entry) => entry.integrationID === providerId);
83
+ for (const entry of entries) {
84
+ try {
85
+ await client.credential.remove({ credentialID: entry.id }, { signal: AbortSignal.timeout(8000) });
86
+ } catch {
87
+ throw new Error('Unable to remove OpenCode credentials; some accounts may already have been removed. Retry to finish disconnecting.');
88
+ }
89
+ }
90
+ return entries.length > 0;
91
+ }
@@ -0,0 +1,57 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { configureOpenCodeCredentials, getProviderAuth, readOpenCodeCredentials, removeProviderAuth, openCodeCredentialSource } from './auth.js';
3
+
4
+ const key = (id, active, value = id) => ({ id, integrationID: 'openai', label: id, active, value: { type: 'key', key: value } });
5
+ afterEach(() => { configureOpenCodeCredentials(null); vi.unstubAllGlobals(); });
6
+
7
+ describe('authoritative host credential API', () => {
8
+ it('observes active account switching and logout without cached or legacy fallback', async () => {
9
+ let entries = [key('old', true), key('new', false)];
10
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => entries } }));
11
+ await expect(getProviderAuth('openai')).resolves.toEqual({ type: 'api', key: 'old' });
12
+ entries = [key('old', false), key('new', true)];
13
+ await expect(getProviderAuth('openai')).resolves.toEqual({ type: 'api', key: 'new' });
14
+ entries = [];
15
+ await expect(getProviderAuth('openai')).resolves.toBeNull();
16
+ });
17
+ it('maps OAuth metadata without copying arbitrary secret-bearing fields', async () => {
18
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => [{ ...key('a', true), value: { type: 'oauth', access: 'access', refresh: 'refresh', expires: 123, metadata: { accountID: 'account', enterpriseUrl: 'https://example.com', unrelated: 'private' } } }] } }));
19
+ await expect(readOpenCodeCredentials()).resolves.toEqual({ openai: { type: 'oauth', access: 'access', refresh: 'refresh', expires: 123, accountId: 'account', enterpriseUrl: 'https://example.com' } });
20
+ });
21
+ it.each([null, {}, { data: [] }, [{ ...key('a', true), value: { type: 'unknown' } }], [key('a', true), key('b', true)]])('rejects unsupported payloads without inventing empty success', async (entries) => {
22
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => entries } }));
23
+ await expect(readOpenCodeCredentials()).rejects.toThrow('Unable to read OpenCode credentials');
24
+ });
25
+ it('redacts transport/auth failures and leaves them retryable', async () => {
26
+ const list = vi.fn().mockRejectedValueOnce(new Error('Authorization: private-token')).mockResolvedValueOnce([]);
27
+ configureOpenCodeCredentials(() => ({ credential: { list } }));
28
+ await expect(readOpenCodeCredentials()).rejects.toThrow(/^Unable to read OpenCode credentials$/);
29
+ await expect(readOpenCodeCredentials()).resolves.toEqual({});
30
+ });
31
+ it('removes all provider accounts, preserves other integrations and supports retry after partial failure', async () => {
32
+ let entries = [key('old', false), key('new', true), { ...key('other', true), integrationID: 'anthropic' }];
33
+ const remove = vi.fn(async ({ credentialID }) => { entries = entries.filter((e) => e.id !== credentialID); });
34
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => entries, remove } }));
35
+ remove.mockImplementationOnce(async ({ credentialID }) => { entries = entries.filter((e) => e.id !== credentialID); });
36
+ remove.mockRejectedValueOnce(new Error('private-token'));
37
+ await expect(removeProviderAuth('openai')).rejects.toThrow('some accounts may already have been removed');
38
+ await expect(removeProviderAuth('openai')).resolves.toBe(true);
39
+ expect(entries.map((e) => e.id)).toEqual(['other']);
40
+ await expect(removeProviderAuth('openai')).resolves.toBe(false);
41
+ });
42
+ it('uses the current endpoint/auth through the real SDK for every read', async () => {
43
+ let origin = 'http://first.invalid';
44
+ let authorization = 'Basic first';
45
+ const requests = [];
46
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
47
+ const req = new Request(input, init);
48
+ requests.push([req.url, req.headers.get('authorization')]);
49
+ return Response.json({ data: [key('current', true)] });
50
+ }));
51
+ configureOpenCodeCredentials(openCodeCredentialSource({ buildOpenCodeUrl: () => origin, getOpenCodeAuthHeaders: () => ({ Authorization: authorization }) }));
52
+ await readOpenCodeCredentials();
53
+ origin = 'http://second.invalid'; authorization = 'Basic second';
54
+ await readOpenCodeCredentials();
55
+ expect(requests).toEqual([['http://first.invalid/api/credential', 'Basic first'], ['http://second.invalid/api/credential', 'Basic second']]);
56
+ });
57
+ });
@@ -114,7 +114,7 @@ function getCommandSources(commandName, workingDirectory) {
114
114
  const jsonSource = getJsonEntrySource(layers, 'command', commandName);
115
115
  const jsonSection = jsonSource.section;
116
116
  const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
117
- const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
117
+ const jsonScope = layers.projectLayers.some((layer) => layer.path === jsonSource.path) ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
118
118
 
119
119
  const sources = {
120
120
  md: {
@@ -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
  };